Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.61% covered (danger)
43.61%
222 / 509
16.00% covered (danger)
16.00%
4 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParsoidHandler
43.61% covered (danger)
43.61%
222 / 509
16.00% covered (danger)
16.00%
4 / 25
3065.06
0.00% covered (danger)
0.00%
0 / 1
 factory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedRequestTypes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 assertDomainIsCorrect
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 getParsedBody
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getOpts
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getRequestAttributes
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 1
462
 getHtmlOutputRendererHelper
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getHtmlInputTransformHelper
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 acceptable
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
306
 tryToCreatePageConfig
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
7
 tryToCreatePageIdentity
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 getTransformEndpoint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageContentEndpoint
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionContentEndpoint
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 wtLint
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
3.88
 wt2html
84.62% covered (warning)
84.62%
77 / 91
0.00% covered (danger)
0.00%
0 / 1
28.46
 newParsoid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseHTML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 html2wt
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 pb2pb
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
90
 updateRedLinks
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 languageConversion
85.42% covered (warning)
85.42%
41 / 48
0.00% covered (danger)
0.00%
0 / 1
4.05
 execute
n/a
0 / 0
n/a
0 / 0
0
 validatePb
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 pageConfigToPageIdentity
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
2.69
1<?php
2/**
3 * Copyright (C) 2011-2020 Wikimedia Foundation and others.
4 *
5 * @license GPL-2.0-or-later
6 */
7
8namespace MediaWiki\Rest\Handler;
9
10use Composer\Semver\Semver;
11use InvalidArgumentException;
12use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
13use LogicException;
14use MediaWiki\Content\WikitextContent;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Language\LanguageCode;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\MainConfigNames;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Page\PageIdentity;
21use MediaWiki\Page\ProperPageIdentity;
22use MediaWiki\Parser\Parser;
23use MediaWiki\Parser\ParserOptions;
24use MediaWiki\Parser\ParserOutput;
25use MediaWiki\Parser\Parsoid\Config\SiteConfig;
26use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
27use MediaWiki\Registration\ExtensionRegistry;
28use MediaWiki\Rest\Handler;
29use MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper;
30use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
31use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper;
32use MediaWiki\Rest\HttpException;
33use MediaWiki\Rest\LocalizedHttpException;
34use MediaWiki\Rest\RequestInterface;
35use MediaWiki\Rest\Response;
36use MediaWiki\Rest\ResponseHeaders;
37use MediaWiki\Revision\MutableRevisionRecord;
38use MediaWiki\Revision\RevisionAccessException;
39use MediaWiki\Revision\RevisionLookup;
40use MediaWiki\Revision\SlotRecord;
41use MediaWiki\Revision\SuppressedDataException;
42use MediaWiki\Title\MalformedTitleException;
43use MediaWiki\Title\Title;
44use MediaWiki\WikiMap\WikiMap;
45use MobileContext;
46use Wikimedia\Http\HttpAcceptParser;
47use Wikimedia\Message\DataMessageValue;
48use Wikimedia\Message\MessageValue;
49use Wikimedia\Parsoid\Config\DataAccess;
50use Wikimedia\Parsoid\Config\PageConfig;
51use Wikimedia\Parsoid\Config\PageConfigFactory;
52use Wikimedia\Parsoid\Core\ClientError;
53use Wikimedia\Parsoid\Core\HtmlPageBundle;
54use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
55use Wikimedia\Parsoid\DOM\Document;
56use Wikimedia\Parsoid\Parsoid;
57use Wikimedia\Parsoid\Utils\ContentUtils;
58use Wikimedia\Parsoid\Utils\DOMCompat;
59use Wikimedia\Parsoid\Utils\DOMUtils;
60use Wikimedia\Parsoid\Utils\Timing;
61
62// TODO logging, timeouts(?), CORS
63// TODO content negotiation (routes.js routes.acceptable)
64// TODO handle MaxConcurrentCallsError (pool counter?)
65
66/**
67 * Base class for Parsoid handlers.
68 * @internal For use by the Parsoid extension
69 */
70abstract class ParsoidHandler extends Handler {
71
72    private RevisionLookup $revisionLookup;
73    protected SiteConfig $siteConfig;
74    protected PageConfigFactory $pageConfigFactory;
75    protected DataAccess $dataAccess;
76
77    /** @var ExtensionRegistry */
78    protected $extensionRegistry;
79
80    /** @var ?StatsdDataFactoryInterface A statistics aggregator */
81    protected $metrics;
82
83    /** @var array */
84    private $requestAttributes;
85
86    public static function factory(): static {
87        $services = MediaWikiServices::getInstance();
88        // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic
89        return new static(
90            $services->getRevisionLookup(),
91            $services->getParsoidSiteConfig(),
92            $services->getParsoidPageConfigFactory(),
93            $services->getParsoidDataAccess()
94        );
95    }
96
97    public function __construct(
98        RevisionLookup $revisionLookup,
99        SiteConfig $siteConfig,
100        PageConfigFactory $pageConfigFactory,
101        DataAccess $dataAccess
102    ) {
103        $this->revisionLookup = $revisionLookup;
104        $this->siteConfig = $siteConfig;
105        $this->pageConfigFactory = $pageConfigFactory;
106        $this->dataAccess = $dataAccess;
107        $this->extensionRegistry = ExtensionRegistry::getInstance();
108        $this->metrics = $siteConfig->metrics();
109    }
110
111    public function getSupportedRequestTypes(): array {
112        return array_merge( parent::getSupportedRequestTypes(), [
113            'application/x-www-form-urlencoded',
114            'multipart/form-data'
115        ] );
116    }
117
118    /**
119     * Verify that the {domain} path parameter matches the actual domain.
120     * @todo Remove this when we no longer need to support the {domain}
121     *       parameter with backwards compatibility with the parsoid
122     *       extension.
123     * @param string $domain Domain name parameter to validate
124     */
125    protected function assertDomainIsCorrect( $domain ): void {
126        // We are cutting some corners here (IDN, non-ASCII casing)
127        // since domain name support is provisional.
128        // TODO use a proper validator instead
129        $server = RequestContext::getMain()->getConfig()->get( MainConfigNames::Server );
130        $expectedDomain = parse_url( $server, PHP_URL_HOST );
131        if ( !$expectedDomain ) {
132            throw new LogicException( 'Cannot parse $wgServer' );
133        }
134        if ( strcasecmp( $expectedDomain, $domain ) === 0 ) {
135            return;
136        }
137
138        // TODO: This should really go away! It's only acceptable because
139        //       this entire method is going to be removed once we no longer
140        //       need the parsoid extension endpoints with the {domain} parameter.
141        if ( $this->extensionRegistry->isLoaded( 'MobileFrontend' ) ) {
142            // @phan-suppress-next-line PhanUndeclaredClassMethod
143            $mobileServer = MobileContext::singleton()->getMobileUrl( $server );
144            $expectedMobileDomain = parse_url( $mobileServer, PHP_URL_HOST );
145            if ( $expectedMobileDomain && strcasecmp( $expectedMobileDomain, $domain ) === 0 ) {
146                return;
147            }
148        }
149
150        $msg = new DataMessageValue(
151            'mwparsoid-invalid-domain',
152            [],
153            'invalid-domain',
154            [ 'expected' => $expectedDomain, 'actual' => $domain, ]
155        );
156
157        throw new LocalizedHttpException( $msg, 400, [
158            'error' => 'parameter-validation-failed',
159            'name' => 'domain',
160            'value' => $domain,
161            'failureCode' => $msg->getCode(),
162            'failureData' => $msg->getData(),
163        ] );
164    }
165
166    /**
167     * Get the parsed body by content-type
168     */
169    protected function getParsedBody(): array {
170        $request = $this->getRequest();
171        [ $contentType ] = explode( ';', $request->getHeader( 'Content-Type' )[0] ?? '', 2 );
172        switch ( $contentType ) {
173            case 'application/x-www-form-urlencoded':
174            case 'multipart/form-data':
175                return $request->getPostParams();
176            case 'application/json':
177                $json = json_decode( $request->getBody()->getContents(), true );
178                if ( !is_array( $json ) ) {
179                    throw new LocalizedHttpException(
180                        new MessageValue( "rest-json-body-parse-error", [ 'not a valid JSON object' ] ), 400 );
181                }
182                return $json;
183            default:
184                throw new LocalizedHttpException(
185                    new MessageValue( "rest-unsupported-content-type", [ $contentType ?? '(null)' ] ),
186                    415
187                );
188        }
189    }
190
191    protected function getOpts( array $body, RequestInterface $request ): array {
192        return array_merge(
193            $body,
194            array_intersect_key( $request->getPathParams(), [ 'from' => true, 'format' => true ] )
195        );
196    }
197
198    /**
199     * Rough equivalent of req.local from Parsoid-JS.
200     * FIXME most of these should be replaced with more native ways of handling the request.
201     * @return array
202     */
203    protected function &getRequestAttributes(): array {
204        if ( $this->requestAttributes ) {
205            return $this->requestAttributes;
206        }
207
208        $request = $this->getRequest();
209        $body = ( $request->getMethod() === 'POST' ) ? $this->getParsedBody() : [];
210        $opts = $this->getOpts( $body, $request );
211        '@phan-var array<string,array|bool|string> $opts'; // @var array<string,array|bool|string> $opts
212        $contentLanguage = $request->getHeaderLine( 'Content-Language' ) ?: null;
213        if ( $contentLanguage ) {
214            $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
215                $contentLanguage
216            );
217        }
218        $attribs = [
219            'pageName' => $request->getPathParam( 'title' ) ?? '',
220            'oldid' => $request->getPathParam( 'revision' ),
221            // "body_only" flag to return just the body (instead of the entire HTML doc)
222            // We would like to deprecate use of this flag: T181657
223            'body_only' => $request->getQueryParams()['body_only'] ?? $body['body_only'] ?? null,
224            'errorEnc' => ParsoidFormatHelper::ERROR_ENCODING[$opts['format']] ?? 'plain',
225            'iwp' => WikiMap::getCurrentWikiId(), // PORT-FIXME verify
226            'offsetType' => $body['offsetType']
227                ?? $request->getQueryParams()['offsetType']
228                // Lint requests should return UCS2 offsets by default
229                ?? ( $opts['format'] === ParsoidFormatHelper::FORMAT_LINT ? 'ucs2' : 'byte' ),
230            'pagelanguage' => $contentLanguage,
231        ];
232
233        // For use in getHtmlOutputRendererHelper
234        $opts['stash'] = $request->getQueryParams()['stash'] ?? false;
235
236        if ( $request->getMethod() === 'POST' ) {
237            if ( isset( $opts['original']['revid'] ) ) {
238                $attribs['oldid'] = $opts['original']['revid'];
239            }
240            if ( isset( $opts['original']['title'] ) ) {
241                $attribs['pageName'] = $opts['original']['title'];
242            }
243        }
244        if ( $attribs['oldid'] !== null ) {
245            if ( $attribs['oldid'] === '' ) {
246                $attribs['oldid'] = null;
247            } else {
248                $attribs['oldid'] = (int)$attribs['oldid'];
249            }
250        }
251
252        // For use in getHtmlOutputRendererHelper
253        $opts['accept-language'] = $request->getHeaderLine( 'Accept-Language' ) ?: null;
254
255        $acceptLanguage = null;
256        if ( $opts['accept-language'] !== null ) {
257            $acceptLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
258                HtmlOutputRendererHelper::getAcceptedTargetLanguage(
259                    $opts['accept-language']
260                )
261            );
262        }
263
264        // Init pageName if oldid is provided and is a valid revision
265        if ( ( $attribs['pageName'] === '' ) && $attribs['oldid'] ) {
266            $rev = $this->revisionLookup->getRevisionById( $attribs['oldid'] );
267            if ( $rev ) {
268                $attribs['pageName'] = $rev->getPage()->getDBkey();
269            }
270        }
271
272        $attribs['envOptions'] = [
273            // We use `prefix` but ought to use `domain` (T206764)
274            'prefix' => $attribs['iwp'],
275            // For the legacy "domain" path parameter used by the endpoints exposed
276            // by the parsoid extension. Will be null for core endpoints.
277            'domain' => $request->getPathParam( 'domain' ),
278            'pageName' => $attribs['pageName'],
279            'cookie' => $request->getHeaderLine( 'Cookie' ),
280            'reqId' => $request->getHeaderLine( 'X-Request-Id' ),
281            'userAgent' => $request->getHeaderLine( 'User-Agent' ),
282            // Used in pb2pb variant updates and wtLint
283            'htmlVariantLanguage' => $acceptLanguage,
284            // Semver::satisfies checks below expect a valid outputContentVersion value.
285            // Better to set it here instead of adding the default value at every check.
286            'outputContentVersion' => Parsoid::defaultHTMLVersion(),
287        ];
288
289        # Convert language codes in $opts['updates']['variant'] if present
290        $sourceVariant = $opts['updates']['variant']['wikitext'] ??
291            $opts['updates']['variant']['source'] ?? null;
292        if ( $sourceVariant ) {
293            $sourceVariant = LanguageCode::normalizeNonstandardCodeAndWarn(
294                $sourceVariant
295            );
296            unset( $opts['updates']['variant']['source'] );
297            $opts['updates']['variant']['wikitext'] = $sourceVariant;
298        }
299        $targetVariant = $opts['updates']['variant']['html'] ??
300            $opts['updates']['variant']['target'] ?? null;
301        if ( $targetVariant ) {
302            $targetVariant = LanguageCode::normalizeNonstandardCodeAndWarn(
303                $targetVariant
304            );
305            unset( $opts['updates']['variant']['target'] );
306            $opts['updates']['variant']['html'] = $targetVariant;
307        }
308        if ( isset( $opts['wikitext']['headers']['content-language'] ) ) {
309            $contentLanguage = $opts['wikitext']['headers']['content-language'];
310            $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
311                $contentLanguage
312            );
313            $opts['wikitext']['headers']['content-language'] = $contentLanguage;
314        }
315        if ( isset( $opts['original']['wikitext']['headers']['content-language'] ) ) {
316            $contentLanguage = $opts['original']['wikitext']['headers']['content-language'];
317            $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn(
318                $contentLanguage
319            );
320            $opts['original']['wikitext']['headers']['content-language'] = $contentLanguage;
321        }
322
323        $attribs['opts'] = $opts;
324
325        // TODO: Remove assertDomainIsCorrect() once we no longer need to support the {domain}
326        //       parameter for the endpoints exposed by the parsoid extension.
327        if ( $attribs['envOptions']['domain'] !== null ) {
328            $this->assertDomainIsCorrect( $attribs['envOptions']['domain'] );
329        }
330
331        $this->requestAttributes = $attribs;
332        return $this->requestAttributes;
333    }
334
335    /**
336     * @param array $attribs
337     * @param ?string $source
338     * @param PageIdentity $page
339     * @param ?int $revId
340     *
341     * @return HtmlOutputRendererHelper
342     */
343    private function getHtmlOutputRendererHelper(
344        array $attribs,
345        ?string $source,
346        PageIdentity $page,
347        ?int $revId
348    ): HtmlOutputRendererHelper {
349        $services = MediaWikiServices::getInstance();
350
351        // Request lenient rev handling
352        $lenientRevHandling = true;
353
354        $authority = $this->getAuthority();
355
356        $params = [];
357        $helper = $services->getPageRestHelperFactory()->newHtmlOutputRendererHelper(
358            $page, $params, $authority, $revId, $lenientRevHandling
359        );
360
361        // XXX: should default to the page's content model?
362        $model = $attribs['opts']['contentmodel']
363            ?? ( $attribs['envOptions']['contentmodel'] ?? CONTENT_MODEL_WIKITEXT );
364
365        if ( $source !== null ) {
366            $helper->setContentSource( $source, $model );
367        }
368
369        if ( isset( $attribs['opts']['stash'] ) ) {
370            $helper->setStashingEnabled( $attribs['opts']['stash'] );
371        }
372
373        if ( isset( $attribs['envOptions']['outputContentVersion'] ) ) {
374            $helper->setOutputProfileVersion( $attribs['envOptions']['outputContentVersion'] );
375        }
376
377        if ( isset( $attribs['pagelanguage'] ) ) {
378            $helper->setPageLanguage( $attribs['pagelanguage'] );
379        }
380
381        if ( isset( $attribs['opts']['accept-language'] ) ) {
382            $helper->setVariantConversionLanguage( $attribs['opts']['accept-language'] );
383        }
384
385        return $helper;
386    }
387
388    /**
389     * @param array $attribs
390     * @param string $html
391     * @param PageIdentity $page
392     *
393     * @return HtmlInputTransformHelper
394     */
395    protected function getHtmlInputTransformHelper(
396        array $attribs,
397        string $html,
398        PageIdentity $page
399    ): HtmlInputTransformHelper {
400        $services = MediaWikiServices::getInstance();
401
402        $parameters = $attribs['opts'] + $attribs;
403        $body = $attribs['opts'];
404
405        $body['html'] = $html;
406
407        $helper = $services->getPageRestHelperFactory()->newHtmlInputTransformHelper(
408            $attribs['envOptions'] + [
409                'offsetType' => $attribs['offsetType'],
410            ],
411            $page,
412            $body,
413            $parameters
414        );
415
416        $helper->setMetrics( $this->siteConfig->prefixedStatsFactory() );
417
418        return $helper;
419    }
420
421    /**
422     * FIXME: Combine with ParsoidFormatHelper::parseContentTypeHeader
423     */
424    private const NEW_SPEC =
425        '#^https://www.mediawiki.org/wiki/Specs/(HTML|pagebundle)/(\d+\.\d+\.\d+)$#D';
426
427    /**
428     * This method checks if we support the requested content formats
429     * As a side-effect, it updates $attribs to set outputContentVersion
430     * that Parsoid should generate based on request headers.
431     *
432     * @param array &$attribs Request attributes from getRequestAttributes()
433     * @return bool
434     */
435    protected function acceptable( array &$attribs ): bool {
436        $request = $this->getRequest();
437        $format = $attribs['opts']['format'];
438
439        if (
440            $format === ParsoidFormatHelper::FORMAT_WIKITEXT ||
441            $format === ParsoidFormatHelper::FORMAT_LINT
442        ) {
443            return true;
444        }
445
446        $acceptHeader = $request->getHeader( 'Accept' );
447        if ( !$acceptHeader ) {
448            return true;
449        }
450
451        $parser = new HttpAcceptParser();
452        $acceptableTypes = $parser->parseAccept( $acceptHeader[0] );  // FIXME: Multiple headers valid?
453        if ( !$acceptableTypes ) {
454            return true;
455        }
456
457        // `acceptableTypes` is already sorted by quality.
458        foreach ( $acceptableTypes as $t ) {
459            $type = "{$t['type']}/{$t['subtype']}";
460            $profile = $t['params']['profile'] ?? null;
461            if (
462                ( $format === ParsoidFormatHelper::FORMAT_HTML && $type === 'text/html' ) ||
463                ( $format === ParsoidFormatHelper::FORMAT_PAGEBUNDLE && $type === 'application/json' )
464            ) {
465                if ( $profile ) {
466                    preg_match( self::NEW_SPEC, $profile, $matches );
467                    if ( $matches && strtolower( $matches[1] ) === $format ) {
468                        $contentVersion = Parsoid::resolveContentVersion( $matches[2] );
469                        if ( $contentVersion ) {
470                            // $attribs mutated here!
471                            $attribs['envOptions']['outputContentVersion'] = $contentVersion;
472                            return true;
473                        } else {
474                            continue;
475                        }
476                    } else {
477                        continue;
478                    }
479                } else {
480                    return true;
481                }
482            } elseif (
483                ( $type === '*/*' ) ||
484                ( $format === ParsoidFormatHelper::FORMAT_HTML && $type === 'text/*' )
485            ) {
486                return true;
487            }
488        }
489
490        return false;
491    }
492
493    /**
494     * Try to create a PageConfig object. If we get an exception (because content
495     * may be missing or inaccessible), throw an appropriate HTTP response object
496     * for callers to handle.
497     *
498     * @param array $attribs
499     * @param ?string $wikitextOverride
500     *   Custom wikitext to use instead of the real content of the page.
501     * @param bool $html2WtMode
502     * @return PageConfig
503     * @throws HttpException
504     */
505    protected function tryToCreatePageConfig(
506        array $attribs, ?string $wikitextOverride = null, bool $html2WtMode = false
507    ): PageConfig {
508        $revId = $attribs['oldid'];
509        $pagelanguageOverride = $attribs['pagelanguage'];
510        $title = $attribs['pageName'];
511
512        $title = ( $title !== '' ) ? Title::newFromText( $title ) : Title::newMainPage();
513        if ( !$title ) {
514            throw new LocalizedHttpException(
515                new MessageValue( "rest-invalid-title", [ 'pageName' ] ), 400
516            );
517        }
518        $user = RequestContext::getMain()->getUser();
519
520        if ( $wikitextOverride === null ) {
521            $revisionRecord = null;
522        } else {
523            // Create a mutable revision record point to the same revision
524            // and set to the desired wikitext.
525            $revisionRecord = new MutableRevisionRecord( $title );
526            // Don't set id to $revId if we have $wikitextOverride
527            // A revision corresponds to specific wikitext, which $wikitextOverride
528            // might not be.
529            $revisionRecord->setId( 0 );
530            $revisionRecord->setSlot(
531                SlotRecord::newUnsaved(
532                    SlotRecord::MAIN,
533                    new WikitextContent( $wikitextOverride )
534                )
535            );
536        }
537
538        $hasOldId = ( $revId !== null );
539        $ensureAccessibleContent = !$html2WtMode || $hasOldId;
540
541        // When transforming for lint, we aren't going to go through a ParserOutputAccess
542        // to checkPreconditions
543        $checkAuthority = (
544            ( $attribs['opts']['format'] ?? '' ) === ParsoidFormatHelper::FORMAT_LINT
545        );
546
547        try {
548            // Note: Parsoid by design isn't supposed to use the user
549            // context right now, and all user state is expected to be
550            // introduced as a post-parse transform.  So although we pass a
551            // User here, it only currently affects the output in obscure
552            // corner cases; see PageConfigFactory::create() for more.
553            // @phan-suppress-next-line PhanUndeclaredMethod method defined in subtype
554            $pageConfig = $this->pageConfigFactory->createFromParserOptions(
555                ParserOptions::newFromUser( $user ),
556                $title,
557                $revisionRecord ?? $revId,
558                $pagelanguageOverride,
559                $ensureAccessibleContent,
560                $checkAuthority
561            );
562        } catch ( SuppressedDataException $e ) {
563            throw new LocalizedHttpException(
564                new MessageValue( "rest-permission-denied-revision", [ $e->getMessage() ] ), 403
565            );
566        } catch ( RevisionAccessException $e ) {
567            throw new LocalizedHttpException(
568                new MessageValue( "rest-specified-revision-unavailable", [ $e->getMessage() ] ), 404
569            );
570        }
571
572        // All good!
573        return $pageConfig;
574    }
575
576    /**
577     * Try to create a PageIdentity object.
578     * If no page is specified in the request, this will return the wiki's main page.
579     * If an invalid page is requested, this throws an appropriate HTTPException.
580     *
581     * @param array $attribs
582     * @return PageIdentity
583     * @throws HttpException
584     */
585    protected function tryToCreatePageIdentity( array $attribs ): PageIdentity {
586        if ( $attribs['pageName'] === '' ) {
587            return Title::newMainPage();
588        }
589
590        // XXX: Should be injected, but the Parsoid extension relies on the
591        //      constructor signature. Also, ParsoidHandler should go away soon anyway.
592        $pageStore = MediaWikiServices::getInstance()->getPageStore();
593
594        $page = $pageStore->getPageByText( $attribs['pageName'] );
595
596        if ( !$page ) {
597            throw new LocalizedHttpException(
598                new MessageValue( "rest-invalid-title", [ 'pageName' ] ), 400
599            );
600        }
601
602        return $page;
603    }
604
605    /**
606     * Get the path for the transform endpoint. May be overwritten to override the path.
607     *
608     * This is done in the parsoid extension, for backwards compatibility
609     * with the old endpoint URLs.
610     *
611     * @stable to override
612     *
613     * @param string $format The format the endpoint is expected to return.
614     *
615     * @return string
616     */
617    protected function getTransformEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
618        return '/coredev/v0/transform/{from}/to/{format}/{title}/{revision}';
619    }
620
621    /**
622     * Get the path for the page content endpoint. May be overwritten to override the path.
623     *
624     * This is done in the parsoid extension, for backwards compatibility
625     * with the old endpoint URLs.
626     *
627     * @stable to override
628     *
629     * @param string $format The format the endpoint is expected to return.
630     *
631     * @return string
632     */
633    protected function getPageContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
634        if ( $format !== ParsoidFormatHelper::FORMAT_HTML ) {
635            throw new InvalidArgumentException( 'Unsupported page content format: ' . $format );
636        }
637        return '/v1/page/{title}/html';
638    }
639
640    /**
641     * Get the path for the page content endpoint. May be overwritten to override the path.
642     *
643     * This is done in the parsoid extension, for backwards compatibility
644     * with the old endpoint URLs.
645     *
646     * @stable to override
647     *
648     * @param string $format The format the endpoint is expected to return.
649     *
650     * @return string
651     */
652    protected function getRevisionContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
653        if ( $format !== ParsoidFormatHelper::FORMAT_HTML ) {
654            throw new InvalidArgumentException( 'Unsupported revision content format: ' . $format );
655        }
656        return '/v1/revision/{revision}/html';
657    }
658
659    private function wtLint(
660        PageConfig $pageConfig, array $attribs, ?array $linterOverrides = []
661    ): array {
662        $envOptions = $attribs['envOptions'] + [
663            'linterOverrides' => $linterOverrides,
664            'offsetType' => $attribs['offsetType'],
665        ];
666        try {
667            $parsoid = $this->newParsoid();
668            $parserOutput = new ParserOutput();
669            return $parsoid->wikitext2lint( $pageConfig, $envOptions, $parserOutput );
670        } catch ( ClientError $e ) {
671            throw new LocalizedHttpException( new MessageValue( "rest-parsoid-error", [ $e->getMessage() ] ), 400 );
672        } catch ( ResourceLimitExceededException $e ) {
673            throw new LocalizedHttpException(
674                new MessageValue( "rest-parsoid-resource-exceeded", [ $e->getMessage() ] ), 413
675            );
676        }
677    }
678
679    /**
680     * Wikitext -> HTML helper.
681     * Spec'd in https://phabricator.wikimedia.org/T75955 and the API tests.
682     *
683     * @param PageConfig $pageConfig
684     * @param array $attribs Request attributes from getRequestAttributes()
685     * @param ?string $wikitext Wikitext to transform (or null to use the
686     *   page specified in the request attributes).
687     *
688     * @return Response
689     */
690    protected function wt2html(
691        PageConfig $pageConfig, array $attribs, ?string $wikitext = null
692    ) {
693        $request = $this->getRequest();
694        $opts = $attribs['opts'];
695        $format = $opts['format'];
696        $oldid = $attribs['oldid'];
697        $stash = $opts['stash'] ?? false;
698
699        if ( $format === ParsoidFormatHelper::FORMAT_LINT ) {
700            $linterOverrides = [];
701            if ( $this->extensionRegistry->isLoaded( 'Linter' ) ) { // T360809
702                $disabled = [];
703                $services = MediaWikiServices::getInstance();
704                $linterCategories = $services->getMainConfig()->get( 'LinterCategories' );
705                foreach ( $linterCategories as $name => $cat ) {
706                    if ( $cat['priority'] === 'none' ) {
707                        $disabled[] = $name;
708                    }
709                }
710                $linterOverrides['disabled'] = $disabled;
711            }
712            $lints = $this->wtLint( $pageConfig, $attribs, $linterOverrides );
713            $response = $this->getResponseFactory()->createJson( $lints );
714            return $response;
715        }
716
717        // TODO: This method should take a PageIdentity + revId,
718        //       to reduce the usage of PageConfig in MW core.
719        $helper = $this->getHtmlOutputRendererHelper(
720            $attribs,
721            $wikitext,
722            $this->pageConfigToPageIdentity( $pageConfig ),
723            // Id will be 0 if we have $wikitext but that isn't valid
724            // to call $helper->setRevision with.  In any case, the revision
725            // will be reset when $helper->setContent is called with $wikitext.
726            // Ideally, the revision would be pass through here instead of
727            // the id and wikitext.
728            $pageConfig->getRevisionId() ?: null
729        );
730
731        $needsPageBundle = ( $format === ParsoidFormatHelper::FORMAT_PAGEBUNDLE );
732
733        if ( $attribs['body_only'] ) {
734            $helper->setFlavor( 'fragment' );
735        } elseif ( !$needsPageBundle ) {
736            // Inline data-parsoid. This will happen when no special params are set.
737            $helper->setFlavor( 'edit' );
738        }
739
740        if ( $wikitext === null && $oldid !== null ) {
741            $mstr = 'pageWithOldid';
742        } else {
743            $mstr = 'wt';
744        }
745
746        $parseTiming = Timing::start();
747
748        if ( $needsPageBundle ) {
749            $pb = $helper->getPageBundle();
750
751            // Handle custom offset requests as a pb2pb transform
752            if (
753                $helper->isParsoidContent() &&
754                ( $attribs['offsetType'] !== 'byte' )
755            ) {
756                $parsoid = $this->newParsoid();
757                $pb = $parsoid->pb2pb(
758                    $pageConfig,
759                    'convertoffsets',
760                    $pb,
761                    [
762                        'inputOffsetType' => 'byte',
763                        'outputOffsetType' => $attribs['offsetType']
764                    ]
765                );
766            }
767
768            if ( $attribs['body_only'] ) {
769                $pb->html = Parser::extractBody( $pb->html );
770            }
771
772            $response = $this->getResponseFactory()->createJson( $pb->responseData() );
773            $helper->putHeaders( $response, false );
774
775            ParsoidFormatHelper::setContentType(
776                $response,
777                ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
778                $pb->version
779            );
780        } else {
781            $out = $helper->getHtml();
782
783            // TODO: offsetType conversion isn't supported right now for non-pagebundle endpoints
784            // Once the OutputTransform framework lands, we might revisit this.
785
786            $response = $this->getResponseFactory()->create();
787            if ( $attribs['body_only'] ) {
788                // body_only must yield body-only output. getContentHolderText()
789                // lazily strips a full-document wrapper, so it both honors
790                // body_only and keeps fixing the variant-conversion case (which
791                // re-wraps the fragment into a full document).
792                $response->getBody()->write( $out->getContentHolderText() );
793            } else {
794                // The 'edit' flavor is a full document (with inline data-parsoid
795                // attributes); emit it from the page bundle.
796                $response->getBody()->write( $helper->getPageBundle()->html );
797            }
798
799            $helper->putHeaders( $response, true );
800
801            // Emit an ETag only if stashing is enabled. It's not reliably useful otherwise.
802            if ( $stash ) {
803                $eTag = $helper->getETag();
804                if ( $eTag ) {
805                    $response->setHeader( 'ETag', $eTag );
806                }
807            }
808        }
809
810        // XXX: For pagebundle requests, this can be somewhat inflated
811        // because of pagebundle json-encoding overheads
812        $outSize = $response->getBody()->getSize();
813        $parseTime = $parseTiming->end();
814
815        // Ignore slow parse metrics for non-oldid parses
816        if ( $mstr === 'pageWithOldid' ) {
817            if ( $parseTime > 3000 ) {
818                LoggerFactory::getInstance( 'slow-parsoid' )
819                    ->info( 'Parsing {title} was slow, took {time} seconds', [
820                        'time' => number_format( $parseTime / 1000, 2 ),
821                        'title' => Title::newFromLinkTarget( $pageConfig->getLinkTarget() )->getPrefixedText(),
822                    ] );
823            }
824
825            if ( $parseTime > 10 && $outSize > 100 ) {
826                // * Don't bother with this metric for really small parse times
827                //   p99 for initialization time is ~7ms according to grafana.
828                //   So, 10ms ensures that startup overheads don't skew the metrics
829                // * For body_only=false requests, <head> section isn't generated
830                //   and if the output is small, per-request overheads can skew
831                //   the timePerKB metrics.
832
833                // NOTE: This is slightly misleading since there are fixed costs
834                // for generating output like the <head> section and should be factored in,
835                // but this is good enough for now as a useful first degree of approximation.
836                $timePerKB = $parseTime * 1024 / $outSize;
837                if ( $timePerKB > 500 ) {
838                    // At 100ms/KB, even a 100KB page which isn't that large will take 10s.
839                    // So, we probably want to shoot for a threshold under 100ms.
840                    // But, let's start with 500ms+ outliers first and see what we uncover.
841                    LoggerFactory::getInstance( 'slow-parsoid' )
842                        ->info( 'Parsing {title} was slow, timePerKB took {timePerKB} ms, total: {time} seconds', [
843                            'time' => number_format( $parseTime / 1000, 2 ),
844                            'timePerKB' => number_format( $timePerKB, 1 ),
845                            'title' => Title::newFromLinkTarget( $pageConfig->getLinkTarget() )->getPrefixedText(),
846                        ] );
847                }
848            }
849        }
850
851        if ( $wikitext !== null ) {
852            // Don't cache requests when wt is set in case somebody uses
853            // GET for wikitext parsing
854            // XXX: can we just refuse to do wikitext parsing in a GET request?
855            $response->setHeader( ResponseHeaders::CACHE_CONTROL, 'private,no-cache,s-maxage=0' );
856        } elseif ( $oldid !== null ) {
857            // XXX: can this go away? Parsoid's PageContent class doesn't expose suppressed revision content.
858            if ( $request->getHeaderLine( 'Cookie' ) ||
859                $request->getHeaderLine( 'Authorization' ) ) {
860                // Don't cache requests with a session.
861                $response->setHeader( ResponseHeaders::CACHE_CONTROL, 'private,no-cache,s-maxage=0' );
862            }
863        }
864        return $response;
865    }
866
867    protected function newParsoid(): Parsoid {
868        return new Parsoid( $this->siteConfig, $this->dataAccess );
869    }
870
871    protected function parseHTML( string $html, bool $validateXMLNames = false ): Document {
872        return DOMUtils::parseHTML( $html, $validateXMLNames );
873    }
874
875    /**
876     * @param PageConfig|PageIdentity $page
877     * @param array $attribs Attributes gotten from requests
878     * @param string $html Original HTML
879     *
880     * @return Response
881     * @throws HttpException
882     */
883    protected function html2wt(
884        $page, array $attribs, string $html
885    ) {
886        if ( $page instanceof PageConfig ) {
887            // TODO: Deprecate passing a PageConfig.
888            //       Ideally, callers would use HtmlToContentTransform directly.
889            $page = Title::newFromLinkTarget( $page->getLinkTarget() );
890        }
891
892        try {
893            $transform = $this->getHtmlInputTransformHelper( $attribs, $html, $page );
894
895            $response = $this->getResponseFactory()->create();
896            $transform->putContent( $response );
897
898            return $response;
899        } catch ( ClientError $e ) {
900            throw new LocalizedHttpException( new MessageValue( "rest-parsoid-error", [ $e->getMessage() ] ), 400 );
901        }
902    }
903
904    /**
905     * Pagebundle -> pagebundle helper.
906     *
907     * @param array<string,array|string> $attribs
908     * @return Response
909     * @throws HttpException
910     */
911    protected function pb2pb( array $attribs ) {
912        $opts = $attribs['opts'];
913
914        $revision = $opts['previous'] ?? $opts['original'] ?? null;
915        if ( !isset( $revision['html'] ) ) {
916            throw new LocalizedHttpException( new MessageValue( "rest-missing-revision-html" ), 400 );
917        }
918
919        $vOriginal = ParsoidFormatHelper::parseContentTypeHeader(
920            $revision['html']['headers']['content-type'] ?? '' );
921        if ( $vOriginal === null ) {
922            throw new LocalizedHttpException( new MessageValue( "rest-missing-revision-html-content-type" ), 400 );
923        }
924        $attribs['envOptions']['inputContentVersion'] = $vOriginal;
925        '@phan-var array<string,array|string> $attribs'; // @var array<string,array|string> $attribs
926
927        $this->metrics->increment(
928            'pb2pb.original.version.' . $attribs['envOptions']['inputContentVersion']
929        );
930
931        if ( !empty( $opts['updates'] ) ) {
932            // FIXME: Handling missing revisions uniformly for all update types
933            // is not probably the right thing to do but probably okay for now.
934            // This might need revisiting as we add newer types.
935            $pageConfig = $this->tryToCreatePageConfig( $attribs, null, true );
936            // If we're only updating parts of the original version, it should
937            // satisfy the requested content version, since we'll be returning
938            // that same one.
939            // FIXME: Since this endpoint applies the acceptable middleware,
940            // `getOutputContentVersion` is not what's been passed in, but what
941            // can be produced.  Maybe that should be selectively applied so
942            // that we can update older versions where it makes sense?
943            // Uncommenting below implies that we can only update the latest
944            // version, since carrot semantics is applied in both directions.
945            // if ( !Semver::satisfies(
946            //     $attribs['envOptions']['inputContentVersion'],
947            //     "^{$attribs['envOptions']['outputContentVersion']}"
948            // ) ) {
949            //  throw new HttpException(
950            //         'We do not know how to do this conversion.', 415
951            //     );
952            // }
953            if ( !empty( $opts['updates']['redlinks'] ) ) {
954                // Q(arlolra): Should redlinks be more complex than a bool?
955                // See gwicke's proposal at T114413#2240381
956                return $this->updateRedLinks( $pageConfig, $attribs, $revision );
957            } elseif ( isset( $opts['updates']['variant'] ) ) {
958                return $this->languageConversion( $pageConfig, $attribs, $revision );
959            } else {
960                throw new LocalizedHttpException( new MessageValue( "rest-unknown-parsoid-transformation" ), 400 );
961            }
962        }
963
964        // TODO(arlolra): subbu has some sage advice in T114413#2365456 that
965        // we should probably be more explicit about the pb2pb conversion
966        // requested rather than this increasingly complex fallback logic.
967        $downgrade = Parsoid::findDowngrade(
968            $attribs['envOptions']['inputContentVersion'],
969            $attribs['envOptions']['outputContentVersion']
970        );
971        if ( $downgrade ) {
972            $pb = HtmlPageBundle::newFromJsonArray( [
973                'html' => $revision['html']['body'],
974                'parsoid' => $revision['data-parsoid']['body'] ?? null,
975                'mw' => $revision['data-mw']['body'] ?? null,
976                'counters' => $revision['counters']['body'] ?? null,
977            ] );
978            $this->validatePb( $pb, $attribs['envOptions']['inputContentVersion'] );
979            Parsoid::downgrade( $downgrade, $pb, $this->siteConfig );
980
981            if ( !empty( $attribs['body_only'] ) ) {
982                $doc = $this->parseHTML( $pb->html );
983                $body = DOMCompat::getBody( $doc );
984                $pb->html = ContentUtils::toXML( $body, [ 'innerXML' => true ] );
985            }
986
987            $response = $this->getResponseFactory()->createJson( $pb->responseData() );
988            ParsoidFormatHelper::setContentType(
989                $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $pb->version
990            );
991            return $response;
992            // Ensure we only reuse from semantically similar content versions.
993        } elseif ( Semver::satisfies( $attribs['envOptions']['outputContentVersion'],
994            '^' . $attribs['envOptions']['inputContentVersion'] ) ) {
995            $pageConfig = $this->tryToCreatePageConfig( $attribs );
996            return $this->wt2html( $pageConfig, $attribs );
997        } else {
998            throw new LocalizedHttpException( new MessageValue( "rest-unsupported-profile-conversion" ), 415 );
999        }
1000    }
1001
1002    /**
1003     * Update red links on a document.
1004     *
1005     * @param PageConfig $pageConfig
1006     * @param array $attribs
1007     * @param array $revision
1008     * @return Response
1009     */
1010    protected function updateRedLinks(
1011        PageConfig $pageConfig, array $attribs, array $revision
1012    ) {
1013        $parsoid = $this->newParsoid();
1014
1015        $pb = HtmlPageBundle::newFromJsonArray( [
1016            'html' => $revision['html']['body'],
1017            'parsoid' => $revision['data-parsoid']['body'] ?? null,
1018            'mw' => $revision['data-mw']['body'] ?? null,
1019            'counters' => $revision['counters']['body'] ?? null,
1020            'version' => $attribs['envOptions']['inputContentVersion'],
1021            'headers' => $revision['html']['headers'] ?? null,
1022            'contentmodel' => $revision['contentmodel'] ?? null,
1023        ] );
1024
1025        $out = $parsoid->pb2pb( $pageConfig, 'redlinks', $pb, [] );
1026
1027        $this->validatePb( $out, $attribs['envOptions']['inputContentVersion'] );
1028
1029        $response = $this->getResponseFactory()->createJson( $out->responseData() );
1030        ParsoidFormatHelper::setContentType(
1031            $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $out->version
1032        );
1033        return $response;
1034    }
1035
1036    /**
1037     * Do variant conversion on a document.
1038     *
1039     * @param PageConfig $pageConfig
1040     * @param array $attribs
1041     * @param array $revision
1042     * @return Response
1043     * @throws HttpException
1044     */
1045    protected function languageConversion(
1046        PageConfig $pageConfig, array $attribs, array $revision
1047    ) {
1048        $opts = $attribs['opts'];
1049        $target = $opts['updates']['variant']['html'] ??
1050            $opts['updates']['variant']['target'] ??
1051            $attribs['envOptions']['htmlVariantLanguage'];
1052        $source = $opts['updates']['variant']['wikitext'] ??
1053            $opts['updates']['variant']['source'] ?? null;
1054
1055        if ( !$target ) {
1056            throw new LocalizedHttpException( new MessageValue( "rest-target-variant-required" ), 400 );
1057        }
1058
1059        $pageIdentity = $this->tryToCreatePageIdentity( $attribs );
1060
1061        $pb = HtmlPageBundle::newFromJsonArray( [
1062            'html' => $revision['html']['body'],
1063            'parsoid' => $revision['data-parsoid']['body'] ?? null,
1064            'mw' => $revision['data-mw']['body'] ?? null,
1065            'counters' => $revision['counters']['body'] ?? null,
1066            'version' => $attribs['envOptions']['inputContentVersion'],
1067            'headers' => $revision['html']['headers'] ?? null,
1068            'contentmodel' => $revision['contentmodel'] ?? null,
1069        ] );
1070
1071        // XXX: DI should inject HtmlTransformFactory
1072        $languageVariantConverter = MediaWikiServices::getInstance()
1073            ->getHtmlTransformFactory()
1074            ->getLanguageVariantConverter( $pageIdentity );
1075        $httpContentLanguage = $attribs['pagelanguage' ] ?? null;
1076        if ( $httpContentLanguage ) {
1077            $languageVariantConverter->setPageLanguageOverride( $httpContentLanguage );
1078        }
1079        // Convert PageBundle to ParserOutput
1080        $parserOutput = PageBundleParserOutputConverter::parserOutputFromPageBundle(
1081            $pb,
1082            title: $pageIdentity,
1083            siteConfig: $this->siteConfig
1084        );
1085        try {
1086            $parserOutput = $languageVariantConverter->convertParserOutputVariant(
1087                $parserOutput, $target, $source,
1088            );
1089        } catch ( InvalidArgumentException $e ) {
1090            throw new LocalizedHttpException(
1091                new MessageValue( "rest-unsupported-language-conversion", [ $source ?? '(unspecified)', $target ] ),
1092                400,
1093                [ 'reason' => $e->getMessage() ]
1094            );
1095        }
1096        $out = PageBundleParserOutputConverter::htmlPageBundleFromParserOutput(
1097            $parserOutput, siteConfig: $this->siteConfig, bodyOnly: false,
1098        );
1099        $out->headers['vary'] ??= 'Accept-Language';
1100
1101        $response = $this->getResponseFactory()->createJson( $out->responseData() );
1102        $response->addHeader( 'Vary', 'Accept-Language' );
1103        ParsoidFormatHelper::setContentType(
1104            $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $out->version
1105        );
1106        return $response;
1107    }
1108
1109    /** @inheritDoc */
1110    abstract public function execute(): Response;
1111
1112    /**
1113     * Validate a HtmlPageBundle against the given contentVersion, and throw
1114     * an HttpException if it does not match.
1115     * @param HtmlPageBundle $pb
1116     * @param string $contentVersion
1117     * @throws HttpException
1118     */
1119    private function validatePb( HtmlPageBundle $pb, string $contentVersion ): void {
1120        $errorMessage = '';
1121        if ( !$pb->validate( $contentVersion, $errorMessage ) ) {
1122            throw new LocalizedHttpException(
1123                new MessageValue( "rest-page-bundle-validation-error", [ $errorMessage ] ),
1124                400
1125            );
1126        }
1127    }
1128
1129    /**
1130     * @param PageConfig $page
1131     *
1132     * @return ProperPageIdentity
1133     * @throws HttpException
1134     */
1135    private function pageConfigToPageIdentity( PageConfig $page ): ProperPageIdentity {
1136        $services = MediaWikiServices::getInstance();
1137
1138        $title = $page->getLinkTarget();
1139        try {
1140            $page = $services->getPageStore()->getPageForLink( $title );
1141        } catch ( MalformedTitleException | InvalidArgumentException ) {
1142            // Note that even some well-formed links are still invalid
1143            // parameters for getPageForLink(), e.g. interwiki links or special pages.
1144            throw new HttpException(
1145                "Bad title: $title", # uses LinkTarget::__toString()
1146                400
1147            );
1148        }
1149
1150        return $page;
1151    }
1152}