Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.33% covered (warning)
70.33%
1057 / 1503
70.85% covered (warning)
70.85%
141 / 199
CRAP
0.00% covered (danger)
0.00%
0 / 1
OutputPage
70.37% covered (warning)
70.37%
1057 / 1502
70.85% covered (warning)
70.85%
141 / 199
7523.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRedirect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCopyrightUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStatusCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addMeta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMetaTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLinkTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCanonicalUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addScript
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addScriptFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addInlineScript
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filterModules
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getModules
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModuleStyles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addModuleStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTarget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addContentOverride
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 addContentOverrideCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addHtmlClasses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeadItemsArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHeadItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHeadItems
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHeadItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addBodyClasses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setArticleBodyOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getArticleBodyOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setProperty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProperty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkLastModified
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
10
 getCdnCacheEpoch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLastModified
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRobotPolicy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getRobotPolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatRobotsOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 setRobotsOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRobotsContent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setIndexPolicy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getIndexPolicy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setFollowPolicy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFollowPolicy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setHTMLTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHTMLTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRedirectedFrom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPageTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setPageTitleMsg
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setPageTitleInternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getPageTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDisplayTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayTitle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUnprefixedDisplayTitle
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 setTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSubtitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addSubtitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildBacklinkSubtitle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 addBacklinkSubtitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearSubtitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubtitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPrintable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPrintable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showNewSectionLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forceHideNewSectionLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSyndicated
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getAdvertisedFeedTypes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setFeedAppendQuery
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addFeedLink
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isSyndicated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSyndicationLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFeedAppendQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setArticleFlag
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isArticle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setArticleRelated
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isArticleRelated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCopyright
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showsCopyright
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 addLanguageLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguageLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addCategoryLinks
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
11.04
 addCategoryLinksToLBAndGetResult
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 setCategoryLinks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCategoryLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCategories
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 setIndicators
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIndicators
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHelpLink
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 disallowUserJs
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getAllowedModules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 reduceAllowedModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prependHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parserOptions
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 setRevisionId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRevisionIsCurrent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRevisionCurrent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setRevisionTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFileVersion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getFileVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileSearchOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWikiTextAsInterface
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 wrapWikiTextAsInterface
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 addWikiTextAsContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addWikiTextTitleInternal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setTOCData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTOCData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOutputFlag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContentLangForJS
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getContentLangForJS
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 addParserOutputMetadata
81.82% covered (warning)
81.82%
54 / 66
0.00% covered (danger)
0.00%
0 / 1
27.46
 getParserOutputText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 addParserOutputContent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 addParserOutputText
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 addParserOutput
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addTemplate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseAsContent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 parseAsInterface
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 parseInlineAsInterface
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseInternal
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 setCdnMaxage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lowerCdnMaxage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 adaptCdnTTL
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 enableClientCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disableClientCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 couldBePublicCached
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 considerCacheSettingsFinal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheVaryCookies
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 haveCacheVaryCookies
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 addVaryHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getVaryHeader
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 addLinkHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLinkHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addAcceptLanguage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 setPreventClickjacking
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreventClickjacking
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFrameOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getReportTo
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
5.50
 getFeaturePolicyReportOnly
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sendCacheControl
87.50% covered (warning)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
10.20
 loadSkinModules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 output
49.33% covered (danger)
49.33%
37 / 75
0.00% covered (danger)
0.00%
0 / 1
106.29
 prepareErrorPage
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 showErrorPage
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 showPermissionsErrorPage
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
342
 versionRequired
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 formatPermissionStatus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 formatPermissionsErrorMessage
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 showLagWarning
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 showFatalError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addReturnTo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 returnToMain
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 showPendingTakeover
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 getRlClientContext
53.12% covered (warning)
53.12%
17 / 32
0.00% covered (danger)
0.00%
0 / 1
14.59
 getRlClient
94.92% covered (success)
94.92%
56 / 59
0.00% covered (danger)
0.00%
0 / 1
9.01
 headElement
90.91% covered (success)
90.91%
40 / 44
0.00% covered (danger)
0.00%
0 / 1
7.04
 getResourceLoader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makeResourceLoaderLink
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 combineWrappedStrings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBottomScripts
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
4.01
 getJsConfigVars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addJsConfigVars
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getJSVars
86.11% covered (warning)
86.11%
93 / 108
0.00% covered (danger)
0.00%
0 / 1
24.42
 getLastSeenUserTalkRevId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 userCanPreview
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 getHeadLinksArray
71.74% covered (warning)
71.74%
66 / 92
0.00% covered (danger)
0.00%
0 / 1
29.03
 getHeadLinksCanonicalURLArray
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 getHeadLinksAlternateURLsArray
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 getHeadLinksCopyrightArray
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
 getHeadLinksSyndicationArray
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
7.32
 feedLink
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 addStyle
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 addInlineStyle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 buildExemptModules
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 buildCssLinksArray
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 styleLink
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
90
 transformResourcePath
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 transformFilePath
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 transformCssMedia
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 addWikiMsg
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWikiMsgArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wrapWikiMsg
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 isTOCEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setupOOUI
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 resetOOUI
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 enableOOUI
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getCSPNonce
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCSP
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCspOutputMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tailElement
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Preparation for the final page rendering.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Output;
24
25use Article;
26use Content;
27use CSSJanus;
28use Exception;
29use ExtensionRegistry;
30use File;
31use HtmlArmor;
32use InvalidArgumentException;
33use JavaScriptContent;
34use Language;
35use LanguageCode;
36use MediaWiki\Cache\LinkCache;
37use MediaWiki\Config\Config;
38use MediaWiki\Context\ContextSource;
39use MediaWiki\Context\IContextSource;
40use MediaWiki\Context\RequestContext;
41use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
42use MediaWiki\Html\Html;
43use MediaWiki\Linker\LinkTarget;
44use MediaWiki\MainConfigNames;
45use MediaWiki\MediaWikiServices;
46use MediaWiki\Message\Message;
47use MediaWiki\Page\PageRecord;
48use MediaWiki\Page\PageReference;
49use MediaWiki\Parser\Parser;
50use MediaWiki\Parser\ParserOutput;
51use MediaWiki\Parser\ParserOutputFlags;
52use MediaWiki\Parser\Sanitizer;
53use MediaWiki\Permissions\PermissionStatus;
54use MediaWiki\Request\ContentSecurityPolicy;
55use MediaWiki\Request\FauxRequest;
56use MediaWiki\Request\WebRequest;
57use MediaWiki\ResourceLoader as RL;
58use MediaWiki\ResourceLoader\ResourceLoader;
59use MediaWiki\Session\SessionManager;
60use MediaWiki\SpecialPage\SpecialPage;
61use MediaWiki\Title\Title;
62use MediaWiki\Title\TitleValue;
63use MediaWiki\Utils\MWTimestamp;
64use MessageSpecifier;
65use MWDebug;
66use OOUI\Element;
67use OOUI\Theme;
68use ParserOptions;
69use RuntimeException;
70use Skin;
71use TextContent;
72use Wikimedia\AtEase\AtEase;
73use Wikimedia\Bcp47Code\Bcp47Code;
74use Wikimedia\LightweightObjectStore\ExpirationAwareness;
75use Wikimedia\Parsoid\Core\TOCData;
76use Wikimedia\Rdbms\IResultWrapper;
77use Wikimedia\RelPath;
78use Wikimedia\WrappedString;
79use Wikimedia\WrappedStringList;
80
81/**
82 * This is one of the Core classes and should
83 * be read at least once by any new developers. Also documented at
84 * https://www.mediawiki.org/wiki/Manual:Architectural_modules/OutputPage
85 *
86 * This class is used to prepare the final rendering. A skin is then
87 * applied to the output parameters (links, javascript, html, categories ...).
88 *
89 * @todo FIXME: Another class handles sending the whole page to the client.
90 *
91 * Some comments comes from a pairing session between Zak Greant and Antoine Musso
92 * in November 2010.
93 *
94 * @todo document
95 */
96class OutputPage extends ContextSource {
97    use ProtectedHookAccessorTrait;
98
99    /** Output CSP policies as headers */
100    public const CSP_HEADERS = 'headers';
101    /** Output CSP policies as meta tags */
102    public const CSP_META = 'meta';
103
104    // Constants for getJSVars()
105    private const JS_VAR_EARLY = 1;
106    private const JS_VAR_LATE = 2;
107
108    // Core config vars that opt-in to JS_VAR_LATE.
109    // Extensions use the 'LateJSConfigVarNames' attribute instead.
110    private const CORE_LATE_JS_CONFIG_VAR_NAMES = [];
111
112    /** @var bool Whether setupOOUI() has been called */
113    private static $oouiSetupDone = false;
114
115    /** @var string[][] Should be private. Used with addMeta() which adds "<meta>" */
116    protected $mMetatags = [];
117
118    /** @var array */
119    protected $mLinktags = [];
120
121    /** @var string|false */
122    protected $mCanonicalUrl = false;
123
124    /**
125     * @var string The contents of <h1>
126     */
127    private $mPageTitle = '';
128
129    /**
130     * @var string The displayed title of the page. Different from page title
131     * if overridden by display title magic word or hooks. Can contain safe
132     * HTML. Different from page title which may contain messages such as
133     * "Editing X" which is displayed in h1. This can be used for other places
134     * where the page name is referred on the page.
135     */
136    private $displayTitle;
137
138    /** @var bool See OutputPage::couldBePublicCached. */
139    private $cacheIsFinal = false;
140
141    /**
142     * @var string Contains all of the "<body>" content. Should be private we
143     *   got set/get accessors and the append() method.
144     */
145    public $mBodytext = '';
146
147    /** @var string Stores contents of "<title>" tag */
148    private $mHTMLtitle = '';
149
150    /**
151     * @var bool Is the displayed content related to the source of the
152     *   corresponding wiki article.
153     */
154    private $mIsArticle = false;
155
156    /** @var bool Stores "article flag" toggle. */
157    private $mIsArticleRelated = true;
158
159    /** @var bool Is the content subject to copyright */
160    private $mHasCopyright = false;
161
162    /**
163     * @var bool We have to set isPrintable(). Some pages should
164     * never be printed (ex: redirections).
165     */
166    private $mPrintable = false;
167
168    /**
169     * @var ?TOCData Table of Contents information from ParserOutput, or
170     *   null if no TOCData was ever set.
171     */
172    private $tocData;
173
174    /**
175     * @var array Contains the page subtitle. Special pages usually have some
176     *   links here. Don't confuse with site subtitle added by skins.
177     */
178    private $mSubtitle = [];
179
180    /** @var string */
181    public $mRedirect = '';
182
183    /** @var int */
184    protected $mStatusCode;
185
186    /**
187     * @var string Used for sending cache control.
188     *   The whole caching system should probably be moved into its own class.
189     */
190    protected $mLastModified = '';
191
192    /**
193     * @var string[][]
194     * @deprecated since 1.38; will be made private (T301020)
195     */
196    protected $mCategoryLinks = [];
197
198    /**
199     * @var string[][]
200     * @deprecated since 1.38, will be made private (T301020)
201     */
202    protected $mCategories = [
203        'hidden' => [],
204        'normal' => [],
205    ];
206
207    /**
208     * @var string[]
209     * @deprecated since 1.38; will be made private (T301020)
210     */
211    protected $mIndicators = [];
212
213    /**
214     * @var string[] Array of Interwiki Prefixed (non DB key) Titles (e.g. 'fr:Test page')
215     */
216    private $mLanguageLinks = [];
217
218    /**
219     * Used for JavaScript (predates ResourceLoader)
220     * @todo We should split JS / CSS.
221     * mScripts content is inserted as is in "<head>" by Skin. This might
222     * contain either a link to a stylesheet or inline CSS.
223     */
224    private $mScripts = '';
225
226    /** @var string Inline CSS styles. Use addInlineStyle() sparingly */
227    protected $mInlineStyles = '';
228
229    /**
230     * Additional <html> classes; This should be rarely modified; prefer mAdditionalBodyClasses.
231     * @var array
232     */
233    protected $mAdditionalHtmlClasses = [];
234
235    /**
236     * @var string[] Array of elements in "<head>". Parser might add its own headers!
237     * @deprecated since 1.38; will be made private (T301020)
238     */
239    protected $mHeadItems = [];
240
241    /** @var array Additional <body> classes; there are also <body> classes from other sources */
242    protected $mAdditionalBodyClasses = [];
243
244    /**
245     * @var array
246     * @deprecated since 1.38; will be made private (T301020)
247     */
248    protected $mModules = [];
249
250    /**
251     * @var array
252     * @deprecated since 1.38; will be made private (T301020)
253     */
254    protected $mModuleStyles = [];
255
256    /** @var ResourceLoader */
257    protected $mResourceLoader;
258
259    /** @var RL\ClientHtml */
260    private $rlClient;
261
262    /** @var RL\Context */
263    private $rlClientContext;
264
265    /** @var array */
266    private $rlExemptStyleModules;
267
268    /**
269     * @var array
270     * @deprecated since 1.38; will be made private (T301020)
271     */
272    protected $mJsConfigVars = [];
273
274    /**
275     * @var array<int,array<string,int>>
276     * @deprecated since 1.38; will be made private (T301020)
277     */
278    protected $mTemplateIds = [];
279
280    /** @var array */
281    protected $mImageTimeKeys = [];
282
283    /** @var string */
284    public $mRedirectCode = '';
285
286    protected $mFeedLinksAppendQuery = null;
287
288    /** @var array
289     * What level of 'untrustworthiness' is allowed in CSS/JS modules loaded on this page?
290     * @see RL\Module::$origin
291     * RL\Module::ORIGIN_ALL is assumed unless overridden;
292     */
293    protected $mAllowedModules = [
294        RL\Module::TYPE_COMBINED => RL\Module::ORIGIN_ALL,
295    ];
296
297    /** @var bool Whether output is disabled.  If this is true, the 'output' method will do nothing. */
298    protected $mDoNothing = false;
299
300    // Parser related.
301
302    /**
303     * lazy initialised, use parserOptions()
304     * @var ParserOptions
305     */
306    protected $mParserOptions = null;
307
308    /**
309     * Handles the Atom / RSS links.
310     * We probably only support Atom in 2011.
311     * @see $wgAdvertisedFeedTypes
312     */
313    private $mFeedLinks = [];
314
315    /**
316     * @var bool Set to false to send no-cache headers, disabling
317     * client-side caching. (This variable should really be named
318     * in the opposite sense; see ::disableClientCache().)
319     * @deprecated since 1.38; will be made private (T301020)
320     */
321    protected $mEnableClientCache = true;
322
323    /** @var bool Flag if output should only contain the body of the article. */
324    private $mArticleBodyOnly = false;
325
326    /**
327     * @var bool
328     * @deprecated since 1.38; will be made private (T301020)
329     */
330    protected $mNewSectionLink = false;
331
332    /**
333     * @var bool
334     * @deprecated since 1.38; will be made private (T301020)
335     */
336    protected $mHideNewSectionLink = false;
337
338    /**
339     * @var bool Comes from the parser. This was probably made to load CSS/JS
340     * only if we had "<gallery>". Used directly in CategoryPage.php.
341     * Looks like ResourceLoader can replace this.
342     * @deprecated since 1.38; will be made private (T301020)
343     */
344    public $mNoGallery = false;
345
346    /** @var int Cache stuff. Looks like mEnableClientCache */
347    protected $mCdnMaxage = 0;
348    /** @var int Upper limit on mCdnMaxage */
349    protected $mCdnMaxageLimit = INF;
350
351    /**
352     * @var bool Controls if anti-clickjacking / frame-breaking headers will
353     * be sent. This should be done for pages where edit actions are possible.
354     * Setter: $this->setPreventClickjacking()
355     */
356    protected $mPreventClickjacking = true;
357
358    /** @var int|null To include the variable {{REVISIONID}} */
359    private $mRevisionId = null;
360
361    /** @var bool|null */
362    private $mRevisionIsCurrent = null;
363
364    /** @var string */
365    private $mRevisionTimestamp = null;
366
367    /** @var array */
368    protected $mFileVersion = null;
369
370    /**
371     * @var array An array of stylesheet filenames (relative from skins path),
372     * with options for CSS media, IE conditions, and RTL/LTR direction.
373     * For internal use; add settings in the skin via $this->addStyle()
374     *
375     * Style again! This seems like a code duplication since we already have
376     * mStyles. This is what makes Open Source amazing.
377     */
378    protected $styles = [];
379
380    private $mIndexPolicy = 'index';
381    private $mFollowPolicy = 'follow';
382
383    /** @var array */
384    private $mRobotsOptions = [ 'max-image-preview' => 'standard' ];
385
386    /**
387     * @var array Headers that cause the cache to vary.  Key is header name,
388     * value should always be null.  (Value was an array of options for
389     * the `Key` header, which was deprecated in 1.32 and removed in 1.34.)
390     */
391    private $mVaryHeader = [
392        'Accept-Encoding' => null,
393    ];
394
395    /**
396     * If the current page was reached through a redirect, $mRedirectedFrom contains the title
397     * of the redirect.
398     *
399     * @var PageReference
400     */
401    private $mRedirectedFrom = null;
402
403    /**
404     * Additional key => value data
405     */
406    private $mProperties = [];
407
408    /**
409     * @var string|null ResourceLoader target for load.php links. If null, will be omitted
410     */
411    private $mTarget = null;
412
413    /**
414     * @var bool Whether parser output contains a table of contents
415     */
416    private $mEnableTOC = false;
417
418    /**
419     * @var array<string,bool> Flags set in the ParserOutput
420     */
421    private $mOutputFlags = [];
422
423    /**
424     * @var string|null The URL to send in a <link> element with rel=license
425     */
426    private $copyrightUrl;
427
428    /**
429     * @var Language|null
430     */
431    private $contentLang;
432
433    /** @var array Profiling data */
434    private $limitReportJSData = [];
435
436    /** @var array Map Title to Content */
437    private $contentOverrides = [];
438
439    /** @var callable[] */
440    private $contentOverrideCallbacks = [];
441
442    /**
443     * Link: header contents
444     */
445    private $mLinkHeader = [];
446
447    /**
448     * @var ContentSecurityPolicy
449     */
450    private $CSP;
451
452    private string $cspOutputMode = self::CSP_HEADERS;
453
454    /**
455     * @var array A cache of the names of the cookies that will influence the cache
456     */
457    private static $cacheVaryCookies = null;
458
459    /**
460     * Constructor for OutputPage. This should not be called directly.
461     * Instead a new RequestContext should be created and it will implicitly create
462     * a OutputPage tied to that context.
463     * @param IContextSource $context
464     */
465    public function __construct( IContextSource $context ) {
466        $this->setContext( $context );
467        $this->CSP = new ContentSecurityPolicy(
468            $context->getRequest()->response(),
469            $context->getConfig(),
470            $this->getHookContainer()
471        );
472    }
473
474    /**
475     * Redirect to $url rather than displaying the normal page
476     *
477     * @param string $url
478     * @param string|int $responsecode HTTP status code
479     */
480    public function redirect( $url, $responsecode = '302' ) {
481        # Strip newlines as a paranoia check for header injection in PHP<5.1.2
482        $this->mRedirect = str_replace( "\n", '', $url );
483        $this->mRedirectCode = (string)$responsecode;
484    }
485
486    /**
487     * Get the URL to redirect to, or an empty string if not redirect URL set
488     *
489     * @return string
490     */
491    public function getRedirect() {
492        return $this->mRedirect;
493    }
494
495    /**
496     * Set the copyright URL to send with the output.
497     * Empty string to omit, null to reset.
498     *
499     * @since 1.26
500     *
501     * @param string|null $url
502     */
503    public function setCopyrightUrl( $url ) {
504        $this->copyrightUrl = $url;
505    }
506
507    /**
508     * Set the HTTP status code to send with the output.
509     *
510     * @param int $statusCode
511     */
512    public function setStatusCode( $statusCode ) {
513        $this->mStatusCode = $statusCode;
514    }
515
516    /**
517     * Add a new "<meta>" tag
518     * To add an http-equiv meta tag, precede the name with "http:"
519     *
520     * @param string $name Name of the meta tag
521     * @param string $val Value of the meta tag
522     */
523    public function addMeta( $name, $val ) {
524        $this->mMetatags[] = [ $name, $val ];
525    }
526
527    /**
528     * Returns the current <meta> tags
529     *
530     * @since 1.25
531     * @return array
532     */
533    public function getMetaTags() {
534        return $this->mMetatags;
535    }
536
537    /**
538     * Add a new \<link\> tag to the page header.
539     *
540     * Note: use setCanonicalUrl() for rel=canonical.
541     *
542     * @param array $linkarr Associative array of attributes.
543     */
544    public function addLink( array $linkarr ) {
545        $this->mLinktags[] = $linkarr;
546    }
547
548    /**
549     * Returns the current <link> tags
550     *
551     * @since 1.25
552     * @return array
553     */
554    public function getLinkTags() {
555        return $this->mLinktags;
556    }
557
558    /**
559     * Set the URL to be used for the <link rel=canonical>. This should be used
560     * in preference to addLink(), to avoid duplicate link tags.
561     * @param string $url
562     */
563    public function setCanonicalUrl( $url ) {
564        $this->mCanonicalUrl = $url;
565    }
566
567    /**
568     * Returns the URL to be used for the <link rel=canonical> if
569     * one is set.
570     *
571     * @since 1.25
572     * @return bool|string
573     */
574    public function getCanonicalUrl() {
575        return $this->mCanonicalUrl;
576    }
577
578    /**
579     * Add raw HTML to the list of scripts (including \<script\> tag, etc.)
580     * Internal use only. Use OutputPage::addModules() or OutputPage::addJsConfigVars()
581     * if possible.
582     *
583     * @param string $script Raw HTML
584     * @param-taint $script exec_html
585     */
586    public function addScript( $script ) {
587        $this->mScripts .= $script;
588    }
589
590    /**
591     * Add a JavaScript file to be loaded as `<script>` on this page.
592     *
593     * Internal use only. Use OutputPage::addModules() if possible.
594     *
595     * @param string $file URL to file (absolute path, protocol-relative, or full url)
596     * @param string|null $unused Previously used to change the cache-busting query parameter
597     */
598    public function addScriptFile( $file, $unused = null ) {
599        $this->addScript( Html::linkedScript( $file ) );
600    }
601
602    /**
603     * Add a self-contained script tag with the given contents
604     * Internal use only. Use OutputPage::addModules() if possible.
605     *
606     * @param string $script JavaScript text, no script tags
607     * @param-taint $script exec_html
608     */
609    public function addInlineScript( $script ) {
610        $this->mScripts .= Html::inlineScript( "\n$script\n" ) . "\n";
611    }
612
613    /**
614     * Filter an array of modules to remove insufficiently trustworthy members, and modules
615     * which are no longer registered (eg a page is cached before an extension is disabled)
616     * @param string[] $modules
617     * @param string|null $position Unused
618     * @param string $type
619     * @return string[]
620     */
621    protected function filterModules( array $modules, $position = null,
622        $type = RL\Module::TYPE_COMBINED
623    ) {
624        $resourceLoader = $this->getResourceLoader();
625        $filteredModules = [];
626        foreach ( $modules as $val ) {
627            $module = $resourceLoader->getModule( $val );
628            if ( $module instanceof RL\Module
629                && $module->getOrigin() <= $this->getAllowedModules( $type )
630            ) {
631                $filteredModules[] = $val;
632            }
633        }
634        return $filteredModules;
635    }
636
637    /**
638     * Get the list of modules to include on this page
639     *
640     * @param bool $filter Whether to filter out insufficiently trustworthy modules
641     * @param string|null $position Unused
642     * @param string $param
643     * @param string $type
644     * @return string[] Array of module names
645     */
646    public function getModules( $filter = false, $position = null, $param = 'mModules',
647        $type = RL\Module::TYPE_COMBINED
648    ) {
649        $modules = array_values( array_unique( $this->$param ) );
650        return $filter
651            ? $this->filterModules( $modules, null, $type )
652            : $modules;
653    }
654
655    /**
656     * Load one or more ResourceLoader modules on this page.
657     *
658     * @param string|array $modules Module name (string) or array of module names
659     */
660    public function addModules( $modules ) {
661        $this->mModules = array_merge( $this->mModules, (array)$modules );
662    }
663
664    /**
665     * Get the list of style-only modules to load on this page.
666     *
667     * @param bool $filter
668     * @param string|null $position Unused
669     * @return string[] Array of module names
670     */
671    public function getModuleStyles( $filter = false, $position = null ) {
672        return $this->getModules( $filter, null, 'mModuleStyles',
673            RL\Module::TYPE_STYLES
674        );
675    }
676
677    /**
678     * Load the styles of one or more style-only ResourceLoader modules on this page.
679     *
680     * Module styles added through this function will be loaded as a stylesheet,
681     * using a standard `<link rel=stylesheet>` HTML tag, rather than as a combined
682     * Javascript and CSS package. Thus, they will even load when JavaScript is disabled.
683     *
684     * @param string|array $modules Module name (string) or array of module names
685     */
686    public function addModuleStyles( $modules ) {
687        $this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules );
688    }
689
690    /**
691     * @return null|string ResourceLoader target
692     */
693    public function getTarget() {
694        return $this->mTarget;
695    }
696
697    /**
698     * Force the given Content object for the given page, for things like page preview.
699     * @see self::addContentOverrideCallback()
700     * @since 1.32
701     * @param LinkTarget|PageReference $target
702     * @param Content $content
703     */
704    public function addContentOverride( $target, Content $content ) {
705        if ( !$this->contentOverrides ) {
706            // Register a callback for $this->contentOverrides on the first call
707            $this->addContentOverrideCallback( function ( $target ) {
708                $key = $target->getNamespace() . ':' . $target->getDBkey();
709                return $this->contentOverrides[$key] ?? null;
710            } );
711        }
712
713        $key = $target->getNamespace() . ':' . $target->getDBkey();
714        $this->contentOverrides[$key] = $content;
715    }
716
717    /**
718     * Add a callback for mapping from a Title to a Content object, for things
719     * like page preview.
720     * @see RL\Context::getContentOverrideCallback()
721     * @since 1.32
722     * @param callable $callback
723     */
724    public function addContentOverrideCallback( callable $callback ) {
725        $this->contentOverrideCallbacks[] = $callback;
726    }
727
728    /**
729     * Add a class to the <html> element. This should rarely be used.
730     * Instead use OutputPage::addBodyClasses() if possible.
731     *
732     * @unstable Experimental since 1.35. Prefer OutputPage::addBodyClasses()
733     * @param string|string[] $classes One or more classes to add
734     */
735    public function addHtmlClasses( $classes ) {
736        $this->mAdditionalHtmlClasses = array_merge( $this->mAdditionalHtmlClasses, (array)$classes );
737    }
738
739    /**
740     * Get an array of head items
741     *
742     * @return string[]
743     */
744    public function getHeadItemsArray() {
745        return $this->mHeadItems;
746    }
747
748    /**
749     * Add or replace a head item to the output
750     *
751     * Whenever possible, use more specific options like ResourceLoader modules,
752     * OutputPage::addLink(), OutputPage::addMeta() and OutputPage::addFeedLink()
753     * Fallback options for those are: OutputPage::addStyle, OutputPage::addScript(),
754     * OutputPage::addInlineScript() and OutputPage::addInlineStyle()
755     * This would be your very LAST fallback.
756     *
757     * @param string $name Item name
758     * @param string $value Raw HTML
759     * @param-taint $value exec_html
760     */
761    public function addHeadItem( $name, $value ) {
762        $this->mHeadItems[$name] = $value;
763    }
764
765    /**
766     * Add one or more head items to the output
767     *
768     * @since 1.28
769     * @param string|string[] $values Raw HTML
770     * @param-taint $values exec_html
771     */
772    public function addHeadItems( $values ) {
773        $this->mHeadItems = array_merge( $this->mHeadItems, (array)$values );
774    }
775
776    /**
777     * Check if the header item $name is already set
778     *
779     * @param string $name Item name
780     * @return bool
781     */
782    public function hasHeadItem( $name ) {
783        return isset( $this->mHeadItems[$name] );
784    }
785
786    /**
787     * Add a class to the <body> element
788     *
789     * @since 1.30
790     * @param string|string[] $classes One or more classes to add
791     */
792    public function addBodyClasses( $classes ) {
793        $this->mAdditionalBodyClasses = array_merge( $this->mAdditionalBodyClasses, (array)$classes );
794    }
795
796    /**
797     * Set whether the output should only contain the body of the article,
798     * without any skin, sidebar, etc.
799     * Used e.g. when calling with "action=render".
800     *
801     * @param bool $only Whether to output only the body of the article
802     */
803    public function setArticleBodyOnly( $only ) {
804        $this->mArticleBodyOnly = $only;
805    }
806
807    /**
808     * Return whether the output will contain only the body of the article
809     *
810     * @return bool
811     */
812    public function getArticleBodyOnly() {
813        return $this->mArticleBodyOnly;
814    }
815
816    /**
817     * Set an additional output property
818     * @since 1.21
819     *
820     * @param string $name
821     * @param mixed $value
822     */
823    public function setProperty( $name, $value ) {
824        $this->mProperties[$name] = $value;
825    }
826
827    /**
828     * Get an additional output property
829     * @since 1.21
830     *
831     * @param string $name
832     * @return mixed Property value or null if not found
833     */
834    public function getProperty( $name ) {
835        return $this->mProperties[$name] ?? null;
836    }
837
838    /**
839     * checkLastModified tells the client to use the client-cached page if
840     * possible. If successful, the OutputPage is disabled so that
841     * any future call to OutputPage->output() have no effect.
842     *
843     * Side effect: sets mLastModified for Last-Modified header
844     *
845     * @param string $timestamp
846     *
847     * @return bool True if cache-ok headers was sent.
848     */
849    public function checkLastModified( $timestamp ) {
850        if ( !$timestamp || $timestamp == '19700101000000' ) {
851            wfDebug( __METHOD__ . ": CACHE DISABLED, NO TIMESTAMP" );
852            return false;
853        }
854        $config = $this->getConfig();
855        if ( !$config->get( MainConfigNames::CachePages ) ) {
856            wfDebug( __METHOD__ . ": CACHE DISABLED" );
857            return false;
858        }
859
860        $timestamp = wfTimestamp( TS_MW, $timestamp );
861        $modifiedTimes = [
862            'page' => $timestamp,
863            'user' => $this->getUser()->getTouched(),
864            'epoch' => $config->get( MainConfigNames::CacheEpoch )
865        ];
866        if ( $config->get( MainConfigNames::UseCdn ) ) {
867            $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, $this->getCdnCacheEpoch(
868                time(),
869                $config->get( MainConfigNames::CdnMaxAge )
870            ) );
871        }
872        $this->getHookRunner()->onOutputPageCheckLastModified( $modifiedTimes, $this );
873
874        $maxModified = max( $modifiedTimes );
875        $this->mLastModified = wfTimestamp( TS_RFC2822, $maxModified );
876
877        $clientHeader = $this->getRequest()->getHeader( 'If-Modified-Since' );
878        if ( $clientHeader === false ) {
879            wfDebug( __METHOD__ . ": client did not send If-Modified-Since header", 'private' );
880            return false;
881        }
882
883        # IE sends sizes after the date like this:
884        # Wed, 20 Aug 2003 06:51:19 GMT; length=5202
885        # this breaks strtotime().
886        $clientHeader = preg_replace( '/;.*$/', '', $clientHeader );
887
888        // E_STRICT system time warnings
889        AtEase::suppressWarnings();
890        $clientHeaderTime = strtotime( $clientHeader );
891        AtEase::restoreWarnings();
892        if ( !$clientHeaderTime ) {
893            wfDebug( __METHOD__
894                . ": unable to parse the client's If-Modified-Since header: $clientHeader" );
895            return false;
896        }
897        $clientHeaderTime = wfTimestamp( TS_MW, $clientHeaderTime );
898
899        # Make debug info
900        $info = '';
901        foreach ( $modifiedTimes as $name => $value ) {
902            if ( $info !== '' ) {
903                $info .= ', ';
904            }
905            $info .= "$name=" . wfTimestamp( TS_ISO_8601, $value );
906        }
907
908        wfDebug( __METHOD__ . ": client sent If-Modified-Since: " .
909            wfTimestamp( TS_ISO_8601, $clientHeaderTime ), 'private' );
910        wfDebug( __METHOD__ . ": effective Last-Modified: " .
911            wfTimestamp( TS_ISO_8601, $maxModified ), 'private' );
912        if ( $clientHeaderTime < $maxModified ) {
913            wfDebug( __METHOD__ . ": STALE, $info", 'private' );
914            return false;
915        }
916
917        # Not modified
918        # Give a 304 Not Modified response code and disable body output
919        wfDebug( __METHOD__ . ": NOT MODIFIED, $info", 'private' );
920        ini_set( 'zlib.output_compression', 0 );
921        $this->getRequest()->response()->statusHeader( 304 );
922        $this->sendCacheControl();
923        $this->disable();
924
925        // Don't output a compressed blob when using ob_gzhandler;
926        // it's technically against HTTP spec and seems to confuse
927        // Firefox when the response gets split over two packets.
928        wfResetOutputBuffers( false );
929
930        return true;
931    }
932
933    /**
934     * @param int $reqTime Time of request (eg. now)
935     * @param int $maxAge Cache TTL in seconds
936     * @return int Timestamp
937     */
938    private function getCdnCacheEpoch( $reqTime, $maxAge ) {
939        // Ensure Last-Modified is never more than $wgCdnMaxAge in the past,
940        // because even if the wiki page content hasn't changed since, static
941        // resources may have changed (skin HTML, interface messages, urls, etc.)
942        // and must roll-over in a timely manner (T46570)
943        return $reqTime - $maxAge;
944    }
945
946    /**
947     * Override the last modified timestamp
948     *
949     * @param string $timestamp New timestamp, in a format readable by
950     *        wfTimestamp()
951     */
952    public function setLastModified( $timestamp ) {
953        $this->mLastModified = wfTimestamp( TS_RFC2822, $timestamp );
954    }
955
956    /**
957     * Set the robot policy for the page: <http://www.robotstxt.org/meta.html>
958     *
959     * @param string $policy The literal string to output as the contents of
960     *   the meta tag.  Will be parsed according to the spec and output in
961     *   standardized form.
962     */
963    public function setRobotPolicy( $policy ) {
964        $policy = Article::formatRobotPolicy( $policy );
965
966        if ( isset( $policy['index'] ) ) {
967            $this->setIndexPolicy( $policy['index'] );
968        }
969        if ( isset( $policy['follow'] ) ) {
970            $this->setFollowPolicy( $policy['follow'] );
971        }
972    }
973
974    /**
975     * Get the current robot policy for the page as a string in the form
976     * <index policy>,<follow policy>.
977     *
978     * @return string
979     */
980    public function getRobotPolicy() {
981        return "{$this->mIndexPolicy},{$this->mFollowPolicy}";
982    }
983
984    /**
985     * Format an array of robots options as a string of directives.
986     *
987     * @return string The robots policy options.
988     */
989    private function formatRobotsOptions(): string {
990        $options = $this->mRobotsOptions;
991        // Check if options array has any non-integer keys.
992        if ( count( array_filter( array_keys( $options ), 'is_string' ) ) > 0 ) {
993            // Robots meta tags can have directives that are single strings or
994            // have parameters that should be formatted like <directive>:<setting>.
995            // If the options keys are strings, format them accordingly.
996            // https://developers.google.com/search/docs/advanced/robots/robots_meta_tag
997            array_walk( $options, static function ( &$value, $key ) {
998                $value = is_string( $key ) ? "{$key}:{$value}" : "{$value}";
999            } );
1000        }
1001        return implode( ',', $options );
1002    }
1003
1004    /**
1005     * Set the robots policy with options for the page.
1006     *
1007     * @since 1.38
1008     * @param array $options An array of key-value pairs or a string
1009     *   to populate the robots meta tag content attribute as a string.
1010     */
1011    public function setRobotsOptions( array $options = [] ): void {
1012        $this->mRobotsOptions = array_merge( $this->mRobotsOptions, $options );
1013    }
1014
1015    /**
1016     * Get the robots policy content attribute for the page
1017     * as a string in the form <index policy>,<follow policy>,<options>.
1018     *
1019     * @return string
1020     */
1021    private function getRobotsContent(): string {
1022        $robotOptionString = $this->formatRobotsOptions();
1023        $robotArgs = ( $this->mIndexPolicy === 'index' &&
1024            $this->mFollowPolicy === 'follow' ) ?
1025            [] :
1026            [
1027                $this->mIndexPolicy,
1028                $this->mFollowPolicy,
1029            ];
1030        if ( $robotOptionString ) {
1031            $robotArgs[] = $robotOptionString;
1032        }
1033        return implode( ',', $robotArgs );
1034    }
1035
1036    /**
1037     * Set the index policy for the page, but leave the follow policy un-
1038     * touched.
1039     *
1040     * @param string $policy Either 'index' or 'noindex'.
1041     */
1042    public function setIndexPolicy( $policy ) {
1043        $policy = trim( $policy );
1044        if ( in_array( $policy, [ 'index', 'noindex' ] ) ) {
1045            $this->mIndexPolicy = $policy;
1046        }
1047    }
1048
1049    /**
1050     * Get the current index policy for the page as a string.
1051     *
1052     * @return string
1053     */
1054    public function getIndexPolicy() {
1055        return $this->mIndexPolicy;
1056    }
1057
1058    /**
1059     * Set the follow policy for the page, but leave the index policy un-
1060     * touched.
1061     *
1062     * @param string $policy Either 'follow' or 'nofollow'.
1063     */
1064    public function setFollowPolicy( $policy ) {
1065        $policy = trim( $policy );
1066        if ( in_array( $policy, [ 'follow', 'nofollow' ] ) ) {
1067            $this->mFollowPolicy = $policy;
1068        }
1069    }
1070
1071    /**
1072     * Get the current follow policy for the page as a string.
1073     *
1074     * @return string
1075     */
1076    public function getFollowPolicy() {
1077        return $this->mFollowPolicy;
1078    }
1079
1080    /**
1081     * "HTML title" means the contents of "<title>".
1082     * It is stored as plain, unescaped text and will be run through htmlspecialchars in the skin file.
1083     *
1084     * @param string|Message $name
1085     */
1086    public function setHTMLTitle( $name ) {
1087        if ( $name instanceof Message ) {
1088            $this->mHTMLtitle = $name->setContext( $this->getContext() )->text();
1089        } else {
1090            $this->mHTMLtitle = $name;
1091        }
1092    }
1093
1094    /**
1095     * Return the "HTML title", i.e. the content of the "<title>" tag.
1096     *
1097     * @return string
1098     */
1099    public function getHTMLTitle() {
1100        return $this->mHTMLtitle;
1101    }
1102
1103    /**
1104     * Set $mRedirectedFrom, the page which redirected us to the current page.
1105     *
1106     * @param PageReference $t
1107     */
1108    public function setRedirectedFrom( PageReference $t ) {
1109        $this->mRedirectedFrom = $t;
1110    }
1111
1112    /**
1113     * "Page title" means the contents of \<h1\>. It is stored as a valid HTML
1114     * fragment. This function allows good tags like \<sup\> in the \<h1\> tag,
1115     * but not bad tags like \<script\>. This function automatically sets
1116     * \<title\> to the same content as \<h1\> but with all tags removed. Bad
1117     * tags that were escaped in \<h1\> will still be escaped in \<title\>, and
1118     * good tags like \<i\> will be dropped entirely.
1119     *
1120     * @param string|Message $name The page title, either as HTML string or
1121     *   as a message which will be formatted with FORMAT_TEXT to yield HTML.
1122     *   Passing a Message is deprecated, since 1.41; please use
1123     *   ::setPageTitleMsg() for that case instead.
1124     * @param-taint $name tainted
1125     * Phan-taint-check gets very confused by $name being either a string or a Message
1126     */
1127    public function setPageTitle( $name ) {
1128        if ( $name instanceof Message ) {
1129            // T343849: passing a Message is deprecated and a warning will
1130            // eventually be emitted here.
1131            $name = $name->setContext( $this->getContext() )->text();
1132        }
1133        $this->setPageTitleInternal( $name );
1134    }
1135
1136    /**
1137     * "Page title" means the contents of \<h1\>. This message takes a
1138     * Message, which will be formatted with FORMAT_ESCAPED to yield
1139     * HTML.  Raw parameters to the message may contain some HTML
1140     * tags; see ::setPageTitle() and Sanitizer::removeSomeTags() for
1141     * details.  This function automatically sets \<title\> to the
1142     * same content as \<h1\> but with all tags removed. Bad tags from
1143     * "raw" parameters that were escaped in \<h1\> will still be
1144     * escaped in \<title\>, and good tags like \<i\> will be dropped
1145     * entirely.
1146     *
1147     * @param Message $msg The page title, as a message which will be
1148     *   formatted with FORMAT_ESCAPED to yield HTML.
1149     * @since 1.41
1150     */
1151    public function setPageTitleMsg( Message $msg ): void {
1152        $this->setPageTitleInternal(
1153            $msg->setContext( $this->getContext() )->escaped()
1154        );
1155    }
1156
1157    private function setPageTitleInternal( string $name ): void {
1158        # change "<script>foo&bar</script>" to "&lt;script&gt;foo&amp;bar&lt;/script&gt;"
1159        # but leave "<i>foobar</i>" alone
1160        $nameWithTags = Sanitizer::removeSomeTags( $name );
1161        $this->mPageTitle = $nameWithTags;
1162
1163        # change "<i>foo&amp;bar</i>" to "foo&bar"
1164        $this->setHTMLTitle(
1165            $this->msg( 'pagetitle' )->plaintextParams( Sanitizer::stripAllTags( $nameWithTags ) )
1166                ->inContentLanguage()
1167        );
1168    }
1169
1170    /**
1171     * Return the "page title", i.e. the content of the \<h1\> tag.
1172     *
1173     * @return string
1174     */
1175    public function getPageTitle() {
1176        return $this->mPageTitle;
1177    }
1178
1179    /**
1180     * Same as page title but only contains name of the page, not any other text.
1181     *
1182     * @since 1.32
1183     * @param string $html Page title text.
1184     * @see OutputPage::setPageTitle
1185     */
1186    public function setDisplayTitle( $html ) {
1187        $this->displayTitle = $html;
1188    }
1189
1190    /**
1191     * Returns page display title.
1192     *
1193     * Performs some normalization, but this not as strict the magic word.
1194     *
1195     * @since 1.32
1196     * @return string HTML
1197     */
1198    public function getDisplayTitle() {
1199        $html = $this->displayTitle;
1200        if ( $html === null ) {
1201            return htmlspecialchars( $this->getTitle()->getPrefixedText(), ENT_NOQUOTES );
1202        }
1203
1204        return Sanitizer::removeSomeTags( $html );
1205    }
1206
1207    /**
1208     * Returns page display title without namespace prefix if possible.
1209     *
1210     * This method is unreliable and best avoided. (T314399)
1211     *
1212     * @since 1.32
1213     * @return string HTML
1214     */
1215    public function getUnprefixedDisplayTitle() {
1216        $service = MediaWikiServices::getInstance();
1217        $languageConverter = $service->getLanguageConverterFactory()
1218            ->getLanguageConverter( $service->getContentLanguage() );
1219        $text = $this->getDisplayTitle();
1220
1221        // Create a regexp with matching groups as placeholders for the namespace, separator and main text
1222        $pageTitleRegexp = "/^" . str_replace(
1223            preg_quote( '(.+?)', '/' ),
1224            '(.+?)',
1225            preg_quote( Parser::formatPageTitle( '(.+?)', '(.+?)', '(.+?)' ), '/' )
1226        ) . "$/";
1227        $matches = [];
1228        if ( preg_match( $pageTitleRegexp, $text, $matches ) ) {
1229            // The regexp above could be manipulated by malicious user input,
1230            // sanitize the result just in case
1231            return Sanitizer::removeSomeTags( $matches[3] );
1232        }
1233
1234        $nsPrefix = $languageConverter->convertNamespace(
1235            $this->getTitle()->getNamespace()
1236        ) . ':';
1237        $prefix = preg_quote( $nsPrefix, '/' );
1238
1239        return preg_replace( "/^$prefix/i", '', $text );
1240    }
1241
1242    /**
1243     * Set the Title object to use
1244     *
1245     * @param PageReference $t
1246     */
1247    public function setTitle( PageReference $t ) {
1248        $t = Title::newFromPageReference( $t );
1249
1250        // @phan-suppress-next-next-line PhanUndeclaredMethod
1251        // @fixme Not all implementations of IContextSource have this method!
1252        $this->getContext()->setTitle( $t );
1253    }
1254
1255    /**
1256     * Replace the subtitle with $str
1257     *
1258     * @param string|Message $str New value of the subtitle. String should be safe HTML.
1259     */
1260    public function setSubtitle( $str ) {
1261        $this->clearSubtitle();
1262        $this->addSubtitle( $str );
1263    }
1264
1265    /**
1266     * Add $str to the subtitle
1267     *
1268     * @param string|Message $str String or Message to add to the subtitle. String should be safe HTML.
1269     * @param-taint $str exec_html
1270     */
1271    public function addSubtitle( $str ) {
1272        if ( $str instanceof Message ) {
1273            $this->mSubtitle[] = $str->setContext( $this->getContext() )->parse();
1274        } else {
1275            $this->mSubtitle[] = $str;
1276        }
1277    }
1278
1279    /**
1280     * Build message object for a subtitle containing a backlink to a page
1281     *
1282     * @since 1.25
1283     * @param PageReference $page Title to link to
1284     * @param array $query Array of additional parameters to include in the link
1285     * @return Message
1286     */
1287    public static function buildBacklinkSubtitle( PageReference $page, $query = [] ) {
1288        if ( $page instanceof PageRecord || $page instanceof Title ) {
1289            // Callers will typically have a PageRecord
1290            if ( $page->isRedirect() ) {
1291                $query['redirect'] = 'no';
1292            }
1293        } elseif ( $page->getNamespace() !== NS_SPECIAL ) {
1294            // We don't know whether it's a redirect, so add the parameter, just to be sure.
1295            $query['redirect'] = 'no';
1296        }
1297
1298        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
1299        return wfMessage( 'backlinksubtitle' )
1300            ->rawParams( $linkRenderer->makeLink( $page, null, [], $query ) );
1301    }
1302
1303    /**
1304     * Add a subtitle containing a backlink to a page
1305     *
1306     * @param PageReference $title Title to link to
1307     * @param array $query Array of additional parameters to include in the link
1308     */
1309    public function addBacklinkSubtitle( PageReference $title, $query = [] ) {
1310        $this->addSubtitle( self::buildBacklinkSubtitle( $title, $query ) );
1311    }
1312
1313    /**
1314     * Clear the subtitles
1315     */
1316    public function clearSubtitle() {
1317        $this->mSubtitle = [];
1318    }
1319
1320    /**
1321     * @return string
1322     */
1323    public function getSubtitle() {
1324        return implode( "<br />\n\t\t\t\t", $this->mSubtitle );
1325    }
1326
1327    /**
1328     * Set the page as printable, i.e. it'll be displayed with all
1329     * print styles included
1330     */
1331    public function setPrintable() {
1332        $this->mPrintable = true;
1333    }
1334
1335    /**
1336     * Return whether the page is "printable"
1337     *
1338     * @return bool
1339     */
1340    public function isPrintable() {
1341        return $this->mPrintable;
1342    }
1343
1344    /**
1345     * Disable output completely, i.e. calling output() will have no effect
1346     */
1347    public function disable() {
1348        $this->mDoNothing = true;
1349    }
1350
1351    /**
1352     * Return whether the output will be completely disabled
1353     *
1354     * @return bool
1355     */
1356    public function isDisabled() {
1357        return $this->mDoNothing;
1358    }
1359
1360    /**
1361     * Show an "add new section" link?
1362     *
1363     * @return bool
1364     */
1365    public function showNewSectionLink() {
1366        return $this->mNewSectionLink;
1367    }
1368
1369    /**
1370     * Forcibly hide the new section link?
1371     *
1372     * @return bool
1373     */
1374    public function forceHideNewSectionLink() {
1375        return $this->mHideNewSectionLink;
1376    }
1377
1378    /**
1379     * Add or remove feed links in the page header
1380     * This is mainly kept for backward compatibility, see OutputPage::addFeedLink()
1381     * for the new version
1382     * @see addFeedLink()
1383     *
1384     * @param bool $show True: add default feeds, false: remove all feeds
1385     */
1386    public function setSyndicated( $show = true ) {
1387        if ( $show ) {
1388            $this->setFeedAppendQuery( false );
1389        } else {
1390            $this->mFeedLinks = [];
1391        }
1392    }
1393
1394    /**
1395     * Return effective list of advertised feed types
1396     * @see addFeedLink()
1397     *
1398     * @return string[] Array of feed type names ( 'rss', 'atom' )
1399     */
1400    protected function getAdvertisedFeedTypes() {
1401        if ( $this->getConfig()->get( MainConfigNames::Feed ) ) {
1402            return $this->getConfig()->get( MainConfigNames::AdvertisedFeedTypes );
1403        } else {
1404            return [];
1405        }
1406    }
1407
1408    /**
1409     * Add default feeds to the page header
1410     * This is mainly kept for backward compatibility, see OutputPage::addFeedLink()
1411     * for the new version
1412     * @see addFeedLink()
1413     *
1414     * @param string|false $val Query to append to feed links or false to output
1415     *        default links
1416     */
1417    public function setFeedAppendQuery( $val ) {
1418        $this->mFeedLinks = [];
1419
1420        foreach ( $this->getAdvertisedFeedTypes() as $type ) {
1421            $query = "feed=$type";
1422            if ( is_string( $val ) ) {
1423                $query .= '&' . $val;
1424            }
1425            $this->mFeedLinks[$type] = $this->getTitle()->getLocalURL( $query );
1426        }
1427    }
1428
1429    /**
1430     * Add a feed link to the page header
1431     *
1432     * @param string $format Feed type, should be a key of $wgFeedClasses
1433     * @param string $href URL
1434     */
1435    public function addFeedLink( $format, $href ) {
1436        if ( in_array( $format, $this->getAdvertisedFeedTypes() ) ) {
1437            $this->mFeedLinks[$format] = $href;
1438        }
1439    }
1440
1441    /**
1442     * Should we output feed links for this page?
1443     * @return bool
1444     */
1445    public function isSyndicated() {
1446        return count( $this->mFeedLinks ) > 0;
1447    }
1448
1449    /**
1450     * Return URLs for each supported syndication format for this page.
1451     * @return array Associating format keys with URLs
1452     */
1453    public function getSyndicationLinks() {
1454        return $this->mFeedLinks;
1455    }
1456
1457    /**
1458     * Will currently always return null
1459     *
1460     * @return null
1461     */
1462    public function getFeedAppendQuery() {
1463        return $this->mFeedLinksAppendQuery;
1464    }
1465
1466    /**
1467     * Set whether the displayed content is related to the source of the
1468     * corresponding article on the wiki
1469     * Setting true will cause the change "article related" toggle to true
1470     *
1471     * @param bool $newVal
1472     */
1473    public function setArticleFlag( $newVal ) {
1474        $this->mIsArticle = $newVal;
1475        if ( $newVal ) {
1476            $this->mIsArticleRelated = $newVal;
1477        }
1478    }
1479
1480    /**
1481     * Return whether the content displayed page is related to the source of
1482     * the corresponding article on the wiki
1483     *
1484     * @return bool
1485     */
1486    public function isArticle() {
1487        return $this->mIsArticle;
1488    }
1489
1490    /**
1491     * Set whether this page is related an article on the wiki
1492     * Setting false will cause the change of "article flag" toggle to false
1493     *
1494     * @param bool $newVal
1495     */
1496    public function setArticleRelated( $newVal ) {
1497        $this->mIsArticleRelated = $newVal;
1498        if ( !$newVal ) {
1499            $this->mIsArticle = false;
1500        }
1501    }
1502
1503    /**
1504     * Return whether this page is related an article on the wiki
1505     *
1506     * @return bool
1507     */
1508    public function isArticleRelated() {
1509        return $this->mIsArticleRelated;
1510    }
1511
1512    /**
1513     * Set whether the standard copyright should be shown for the current page.
1514     *
1515     * @param bool $hasCopyright
1516     */
1517    public function setCopyright( $hasCopyright ) {
1518        $this->mHasCopyright = $hasCopyright;
1519    }
1520
1521    /**
1522     * Return whether the standard copyright should be shown for the current page.
1523     * By default, it is true for all articles but other pages
1524     * can signal it by using setCopyright( true ).
1525     *
1526     * Used by SkinTemplate to decided whether to show the copyright.
1527     *
1528     * @return bool
1529     */
1530    public function showsCopyright() {
1531        return $this->isArticle() || $this->mHasCopyright;
1532    }
1533
1534    /**
1535     * Add new language links
1536     *
1537     * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles
1538     *                               (e.g. 'fr:Test page')
1539     */
1540    public function addLanguageLinks( array $newLinkArray ) {
1541        $this->mLanguageLinks = array_merge( $this->mLanguageLinks, $newLinkArray );
1542    }
1543
1544    /**
1545     * Reset the language links and add new language links
1546     *
1547     * @param string[] $newLinkArray Array of interwiki-prefixed (non DB key) titles
1548     *                               (e.g. 'fr:Test page')
1549     */
1550    public function setLanguageLinks( array $newLinkArray ) {
1551        $this->mLanguageLinks = $newLinkArray;
1552    }
1553
1554    /**
1555     * Get the list of language links
1556     *
1557     * @return string[] Array of interwiki-prefixed (non DB key) titles (e.g. 'fr:Test page')
1558     */
1559    public function getLanguageLinks() {
1560        return $this->mLanguageLinks;
1561    }
1562
1563    /**
1564     * Add an array of categories, with names in the keys
1565     *
1566     * @param array $categories Mapping category name => sort key
1567     */
1568    public function addCategoryLinks( array $categories ) {
1569        if ( !$categories ) {
1570            return;
1571        }
1572
1573        $res = $this->addCategoryLinksToLBAndGetResult( $categories );
1574
1575        # Set all the values to 'normal'.
1576        $categories = array_fill_keys( array_keys( $categories ), 'normal' );
1577        $pageData = [];
1578
1579        # Mark hidden categories
1580        foreach ( $res as $row ) {
1581            if ( isset( $row->pp_value ) ) {
1582                $categories[$row->page_title] = 'hidden';
1583            }
1584            // Page exists, cache results
1585            if ( isset( $row->page_id ) ) {
1586                $pageData[$row->page_title] = $row;
1587            }
1588        }
1589
1590        # Add the remaining categories to the skin
1591        if ( $this->getHookRunner()->onOutputPageMakeCategoryLinks(
1592            $this, $categories, $this->mCategoryLinks )
1593        ) {
1594            $services = MediaWikiServices::getInstance();
1595            $linkRenderer = $services->getLinkRenderer();
1596            $languageConverter = $services->getLanguageConverterFactory()
1597                ->getLanguageConverter( $services->getContentLanguage() );
1598            foreach ( $categories as $category => $type ) {
1599                // array keys will cast numeric category names to ints, so cast back to string
1600                $category = (string)$category;
1601                $origcategory = $category;
1602                if ( array_key_exists( $category, $pageData ) ) {
1603                    $title = Title::newFromRow( $pageData[$category] );
1604                } else {
1605                    $title = Title::makeTitleSafe( NS_CATEGORY, $category );
1606                }
1607                if ( !$title ) {
1608                    continue;
1609                }
1610                $languageConverter->findVariantLink( $category, $title, true );
1611
1612                if ( $category != $origcategory && array_key_exists( $category, $categories ) ) {
1613                    continue;
1614                }
1615                $text = $languageConverter->convertHtml( $title->getText() );
1616                $this->mCategories[$type][] = $title->getText();
1617                $this->mCategoryLinks[$type][] = $linkRenderer->makeLink( $title, new HtmlArmor( $text ) );
1618            }
1619        }
1620    }
1621
1622    /**
1623     * @param array $categories
1624     * @return IResultWrapper
1625     */
1626    protected function addCategoryLinksToLBAndGetResult( array $categories ) {
1627        # Add the links to a LinkBatch
1628        $arr = [ NS_CATEGORY => $categories ];
1629        $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
1630        $lb = $linkBatchFactory->newLinkBatch();
1631        $lb->setArray( $arr );
1632
1633        # Fetch existence plus the hiddencat property
1634        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1635        $fields = array_merge(
1636            LinkCache::getSelectFields(),
1637            [ 'pp_value' ]
1638        );
1639
1640        $res = $dbr->newSelectQueryBuilder()
1641            ->select( $fields )
1642            ->from( 'page' )
1643            ->leftJoin( 'page_props', null, [
1644                'pp_propname' => 'hiddencat',
1645                'pp_page = page_id',
1646            ] )
1647            ->where( $lb->constructSet( 'page', $dbr ) )
1648            ->caller( __METHOD__ )
1649            ->fetchResultSet();
1650
1651        # Add the results to the link cache
1652        $linkCache = MediaWikiServices::getInstance()->getLinkCache();
1653        $lb->addResultToCache( $linkCache, $res );
1654
1655        return $res;
1656    }
1657
1658    /**
1659     * Reset the category links (but not the category list) and add $categories
1660     *
1661     * @param array $categories Mapping category name => sort key
1662     */
1663    public function setCategoryLinks( array $categories ) {
1664        $this->mCategoryLinks = [];
1665        $this->addCategoryLinks( $categories );
1666    }
1667
1668    /**
1669     * Get the list of category links, in a 2-D array with the following format:
1670     * $arr[$type][] = $link, where $type is either "normal" or "hidden" (for
1671     * hidden categories) and $link a HTML fragment with a link to the category
1672     * page
1673     *
1674     * @return string[][]
1675     * @return-taint none
1676     */
1677    public function getCategoryLinks() {
1678        return $this->mCategoryLinks;
1679    }
1680
1681    /**
1682     * Get the list of category names this page belongs to.
1683     *
1684     * @param string $type The type of categories which should be returned. Possible values:
1685     *  * all: all categories of all types
1686     *  * hidden: only the hidden categories
1687     *  * normal: all categories, except hidden categories
1688     * @return string[]
1689     */
1690    public function getCategories( $type = 'all' ) {
1691        if ( $type === 'all' ) {
1692            $allCategories = [];
1693            foreach ( $this->mCategories as $categories ) {
1694                $allCategories = array_merge( $allCategories, $categories );
1695            }
1696            return $allCategories;
1697        }
1698        if ( !isset( $this->mCategories[$type] ) ) {
1699            throw new InvalidArgumentException( 'Invalid category type given: ' . $type );
1700        }
1701        return $this->mCategories[$type];
1702    }
1703
1704    /**
1705     * Add an array of indicators, with their identifiers as array
1706     * keys and HTML contents as values.
1707     *
1708     * In case of duplicate keys, existing values are overwritten.
1709     *
1710     * @note External code which calls this method should ensure that
1711     * any indicators sourced from parsed wikitext are wrapped with
1712     * the appropriate class; see note in ::getIndicators().
1713     *
1714     * @param string[] $indicators
1715     * @param-taint $indicators exec_html
1716     * @since 1.25
1717     */
1718    public function setIndicators( array $indicators ) {
1719        $this->mIndicators = $indicators + $this->mIndicators;
1720        // Keep ordered by key
1721        ksort( $this->mIndicators );
1722    }
1723
1724    /**
1725     * Get the indicators associated with this page.
1726     *
1727     * The array will be internally ordered by item keys.
1728     *
1729     * @return string[] Keys: identifiers, values: HTML contents
1730     * @since 1.25
1731     */
1732    public function getIndicators(): array {
1733        // Note that some -- but not all -- indicators will be wrapped
1734        // with a class appropriate for user-generated wikitext content
1735        // (usually .mw-parser-output). The exceptions would be an
1736        // indicator added via ::addHelpLink() below, which adds content
1737        // which don't come from the parser and is not user-generated;
1738        // and any indicators added by extensions which may call
1739        // OutputPage::setIndicators() directly.  In the latter case the
1740        // caller is responsible for wrapping any parser-generated
1741        // indicators.
1742        return $this->mIndicators;
1743    }
1744
1745    /**
1746     * Adds help link with an icon via page indicators.
1747     * Link target can be overridden by a local message containing a wikilink:
1748     * the message key is: lowercase action or special page name + '-helppage'.
1749     * @param string $to Target MediaWiki.org page title or encoded URL.
1750     * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MediaWiki.org.
1751     * @since 1.25
1752     */
1753    public function addHelpLink( $to, $overrideBaseUrl = false ) {
1754        $this->addModuleStyles( 'mediawiki.helplink' );
1755        $text = $this->msg( 'helppage-top-gethelp' )->escaped();
1756
1757        if ( $overrideBaseUrl ) {
1758            $helpUrl = $to;
1759        } else {
1760            $toUrlencoded = wfUrlencode( str_replace( ' ', '_', $to ) );
1761            $helpUrl = "https://www.mediawiki.org/wiki/Special:MyLanguage/$toUrlencoded";
1762        }
1763
1764        $link = Html::rawElement(
1765            'a',
1766            [
1767                'href' => $helpUrl,
1768                'target' => '_blank',
1769                'class' => 'mw-helplink',
1770            ],
1771            $text
1772        );
1773
1774        // See note in ::getIndicators() above -- unlike wikitext-generated
1775        // indicators which come from ParserOutput, this indicator will not
1776        // be wrapped.
1777        $this->setIndicators( [ 'mw-helplink' => $link ] );
1778    }
1779
1780    /**
1781     * Do not allow scripts which can be modified by wiki users to load on this page;
1782     * only allow scripts bundled with, or generated by, the software.
1783     * Site-wide styles are controlled by a config setting, since they can be
1784     * used to create a custom skin/theme, but not user-specific ones.
1785     *
1786     * @todo this should be given a more accurate name
1787     */
1788    public function disallowUserJs() {
1789        $this->reduceAllowedModules(
1790            RL\Module::TYPE_SCRIPTS,
1791            RL\Module::ORIGIN_CORE_INDIVIDUAL
1792        );
1793
1794        // Site-wide styles are controlled by a config setting, see T73621
1795        // for background on why. User styles are never allowed.
1796        if ( $this->getConfig()->get( MainConfigNames::AllowSiteCSSOnRestrictedPages ) ) {
1797            $styleOrigin = RL\Module::ORIGIN_USER_SITEWIDE;
1798        } else {
1799            $styleOrigin = RL\Module::ORIGIN_CORE_INDIVIDUAL;
1800        }
1801        $this->reduceAllowedModules(
1802            RL\Module::TYPE_STYLES,
1803            $styleOrigin
1804        );
1805    }
1806
1807    /**
1808     * Show what level of JavaScript / CSS untrustworthiness is allowed on this page
1809     * @see RL\Module::$origin
1810     * @param string $type RL\Module TYPE_ constant
1811     * @return int Module ORIGIN_ class constant
1812     */
1813    public function getAllowedModules( $type ) {
1814        if ( $type == RL\Module::TYPE_COMBINED ) {
1815            return min( array_values( $this->mAllowedModules ) );
1816        } else {
1817            return $this->mAllowedModules[$type] ?? RL\Module::ORIGIN_ALL;
1818        }
1819    }
1820
1821    /**
1822     * Limit the highest level of CSS/JS untrustworthiness allowed.
1823     *
1824     * If passed the same or a higher level than the current level of untrustworthiness set, the
1825     * level will remain unchanged.
1826     *
1827     * @param string $type
1828     * @param int $level RL\Module class constant
1829     */
1830    public function reduceAllowedModules( $type, $level ) {
1831        $this->mAllowedModules[$type] = min( $this->getAllowedModules( $type ), $level );
1832    }
1833
1834    /**
1835     * Prepend $text to the body HTML
1836     *
1837     * @param string $text HTML
1838     * @param-taint $text exec_html
1839     */
1840    public function prependHTML( $text ) {
1841        $this->mBodytext = $text . $this->mBodytext;
1842    }
1843
1844    /**
1845     * Append $text to the body HTML
1846     *
1847     * @param string $text HTML
1848     * @param-taint $text exec_html
1849     */
1850    public function addHTML( $text ) {
1851        $this->mBodytext .= $text;
1852    }
1853
1854    /**
1855     * Shortcut for adding an Html::element via addHTML.
1856     *
1857     * @since 1.19
1858     *
1859     * @param string $element
1860     * @param array $attribs
1861     * @param string $contents
1862     */
1863    public function addElement( $element, array $attribs = [], $contents = '' ) {
1864        $this->addHTML( Html::element( $element, $attribs, $contents ) );
1865    }
1866
1867    /**
1868     * Clear the body HTML
1869     */
1870    public function clearHTML() {
1871        $this->mBodytext = '';
1872    }
1873
1874    /**
1875     * Get the body HTML
1876     *
1877     * @return string HTML
1878     */
1879    public function getHTML() {
1880        return $this->mBodytext;
1881    }
1882
1883    /**
1884     * Get/set the ParserOptions object to use for wikitext parsing
1885     *
1886     * @return ParserOptions
1887     */
1888    public function parserOptions() {
1889        if ( !$this->mParserOptions ) {
1890            if ( !$this->getUser()->isSafeToLoad() ) {
1891                // Context user isn't unstubbable yet, so don't try to get a
1892                // ParserOptions for it. And don't cache this ParserOptions
1893                // either.
1894                $po = ParserOptions::newFromAnon();
1895                $po->setAllowUnsafeRawHtml( false );
1896                return $po;
1897            }
1898
1899            $this->mParserOptions = ParserOptions::newFromContext( $this->getContext() );
1900            $this->mParserOptions->setAllowUnsafeRawHtml( false );
1901        }
1902
1903        return $this->mParserOptions;
1904    }
1905
1906    /**
1907     * Set the revision ID which will be seen by the wiki text parser
1908     * for things such as embedded {{REVISIONID}} variable use.
1909     *
1910     * @param int|null $revid A positive integer, or null
1911     * @return mixed Previous value
1912     */
1913    public function setRevisionId( $revid ) {
1914        $val = $revid === null ? null : intval( $revid );
1915        return wfSetVar( $this->mRevisionId, $val, true );
1916    }
1917
1918    /**
1919     * Get the displayed revision ID
1920     *
1921     * @return int|null
1922     */
1923    public function getRevisionId() {
1924        return $this->mRevisionId;
1925    }
1926
1927    /**
1928     * Set whether the revision displayed (as set in ::setRevisionId())
1929     * is the latest revision of the page.
1930     *
1931     * @param bool $isCurrent
1932     */
1933    public function setRevisionIsCurrent( bool $isCurrent ): void {
1934        $this->mRevisionIsCurrent = $isCurrent;
1935    }
1936
1937    /**
1938     * Whether the revision displayed is the latest revision of the page
1939     *
1940     * @since 1.34
1941     * @return bool
1942     */
1943    public function isRevisionCurrent(): bool {
1944        return $this->mRevisionId == 0 || (
1945            $this->mRevisionIsCurrent ?? (
1946                $this->mRevisionId == $this->getTitle()->getLatestRevID()
1947            )
1948        );
1949    }
1950
1951    /**
1952     * Set the timestamp of the revision which will be displayed. This is used
1953     * to avoid a extra DB call in SkinComponentFooter::lastModified().
1954     *
1955     * @param string|null $timestamp
1956     * @return mixed Previous value
1957     */
1958    public function setRevisionTimestamp( $timestamp ) {
1959        return wfSetVar( $this->mRevisionTimestamp, $timestamp, true );
1960    }
1961
1962    /**
1963     * Get the timestamp of displayed revision.
1964     * This will be null if not filled by setRevisionTimestamp().
1965     *
1966     * @return string|null
1967     */
1968    public function getRevisionTimestamp() {
1969        return $this->mRevisionTimestamp;
1970    }
1971
1972    /**
1973     * Set the displayed file version
1974     *
1975     * @param File|null $file
1976     * @return mixed Previous value
1977     */
1978    public function setFileVersion( $file ) {
1979        $val = null;
1980        if ( $file instanceof File && $file->exists() ) {
1981            $val = [ 'time' => $file->getTimestamp(), 'sha1' => $file->getSha1() ];
1982        }
1983        return wfSetVar( $this->mFileVersion, $val, true );
1984    }
1985
1986    /**
1987     * Get the displayed file version
1988     *
1989     * @return array|null ('time' => MW timestamp, 'sha1' => sha1)
1990     */
1991    public function getFileVersion() {
1992        return $this->mFileVersion;
1993    }
1994
1995    /**
1996     * Get the templates used on this page
1997     *
1998     * @return array<int,array<string,int>> (namespace => dbKey => revId)
1999     * @since 1.18
2000     */
2001    public function getTemplateIds() {
2002        return $this->mTemplateIds;
2003    }
2004
2005    /**
2006     * Get the files used on this page
2007     *
2008     * @return array [ dbKey => [ 'time' => MW timestamp or null, 'sha1' => sha1 or '' ] ]
2009     * @since 1.18
2010     */
2011    public function getFileSearchOptions() {
2012        return $this->mImageTimeKeys;
2013    }
2014
2015    /**
2016     * Convert wikitext *in the user interface language* to HTML and
2017     * add it to the buffer. The result will not be
2018     * language-converted, as user interface messages are already
2019     * localized into a specific variant.  Assumes that the current
2020     * page title will be used if optional $title is not
2021     * provided. Output will be tidy.
2022     *
2023     * @param string $text Wikitext in the user interface language
2024     * @param bool $linestart Is this the start of a line? (Defaults to true)
2025     * @param PageReference|null $title Optional title to use; default of `null`
2026     *   means use current page title.
2027     * @since 1.32
2028     */
2029    public function addWikiTextAsInterface(
2030        $text, $linestart = true, PageReference $title = null
2031    ) {
2032        $title ??= $this->getTitle();
2033        if ( !$title ) {
2034            throw new RuntimeException( 'Title is null' );
2035        }
2036        $this->addWikiTextTitleInternal( $text, $title, $linestart, /*interface*/true );
2037    }
2038
2039    /**
2040     * Convert wikitext *in the user interface language* to HTML and
2041     * add it to the buffer with a `<div class="$wrapperClass">`
2042     * wrapper.  The result will not be language-converted, as user
2043     * interface messages as already localized into a specific
2044     * variant.  The $text will be parsed in start-of-line context.
2045     * Output will be tidy.
2046     *
2047     * @param string $wrapperClass The class attribute value for the <div>
2048     *   wrapper in the output HTML
2049     * @param string $text Wikitext in the user interface language
2050     * @since 1.32
2051     */
2052    public function wrapWikiTextAsInterface(
2053        $wrapperClass, $text
2054    ) {
2055        $this->addWikiTextTitleInternal(
2056            $text, $this->getTitle(),
2057            /*linestart*/true, /*interface*/true,
2058            $wrapperClass
2059        );
2060    }
2061
2062    /**
2063     * Convert wikitext *in the page content language* to HTML and add
2064     * it to the buffer.  The result with be language-converted to the
2065     * user's preferred variant.  Assumes that the current page title
2066     * will be used if optional $title is not provided. Output will be
2067     * tidy.
2068     *
2069     * @param string $text Wikitext in the page content language
2070     * @param bool $linestart Is this the start of a line? (Defaults to true)
2071     * @param PageReference|null $title Optional title to use; default of `null`
2072     *   means use current page title.
2073     * @since 1.32
2074     */
2075    public function addWikiTextAsContent(
2076        $text, $linestart = true, PageReference $title = null
2077    ) {
2078        $title ??= $this->getTitle();
2079        if ( !$title ) {
2080            throw new RuntimeException( 'Title is null' );
2081        }
2082        $this->addWikiTextTitleInternal( $text, $title, $linestart, /*interface*/false );
2083    }
2084
2085    /**
2086     * Add wikitext with a custom Title object.
2087     * Output is unwrapped.
2088     *
2089     * @param string $text Wikitext
2090     * @param PageReference $title
2091     * @param bool $linestart Is this the start of a line?@param
2092     * @param bool $interface Whether it is an interface message
2093     *   (for example disables conversion)
2094     * @param string|null $wrapperClass if not empty, wraps the output in
2095     *   a `<div class="$wrapperClass">`
2096     */
2097    private function addWikiTextTitleInternal(
2098        $text, PageReference $title, $linestart, $interface, $wrapperClass = null
2099    ) {
2100        $parserOutput = $this->parseInternal(
2101            $text, $title, $linestart, $interface
2102        );
2103
2104        $this->addParserOutput( $parserOutput, [
2105            'enableSectionEditLinks' => false,
2106            'wrapperDivClass' => $wrapperClass ?? '',
2107        ] );
2108    }
2109
2110    /**
2111     * Adds Table of Contents data to OutputPage from ParserOutput
2112     * @param TOCData $tocData
2113     * @internal For use by Article.php
2114     */
2115    public function setTOCData( TOCData $tocData ) {
2116        $this->tocData = $tocData;
2117    }
2118
2119    /**
2120     * @internal For usage in Skin::getTOCData() only.
2121     * @return ?TOCData Table of Contents data, or
2122     *   null if OutputPage::setTOCData() has not been called.
2123     */
2124    public function getTOCData(): ?TOCData {
2125        return $this->tocData;
2126    }
2127
2128    /**
2129     * @internal Will be replaced by direct access to
2130     *  ParserOutput::getOutputFlag()
2131     * @param string $name A flag name from ParserOutputFlags
2132     * @return bool
2133     */
2134    public function getOutputFlag( string $name ): bool {
2135        return isset( $this->mOutputFlags[$name] );
2136    }
2137
2138    /**
2139     * @internal For use by ViewAction/Article only
2140     * @since 1.42
2141     * @param Bcp47Code $lang
2142     */
2143    public function setContentLangForJS( Bcp47Code $lang ): void {
2144        $this->contentLang = MediaWikiServices::getInstance()->getLanguageFactory()
2145            ->getLanguage( $lang );
2146    }
2147
2148    /**
2149     * Which language getJSVars should use
2150     *
2151     * Use of this is strongly discouraged in favour of ParserOutput::getLanguage(),
2152     * and should not be needed in most cases given that ParserOutput::getText()
2153     * already takes care of 'lang' and 'dir' attributes.
2154     *
2155     * Consider whether RequestContext::getLanguage (e.g. OutputPage::getLanguage
2156     * or Skin::getLanguage) or MediaWikiServices::getContentLanguage is more
2157     * appropiate first for your use case.
2158     *
2159     * @since 1.42
2160     * @return Language
2161     */
2162    private function getContentLangForJS(): Language {
2163        if ( !$this->contentLang ) {
2164            // If this is not set, then we're likely not on in a request that renders page content
2165            // (e.g. ViewAction or ApiParse), but rather a different Action or SpecialPage.
2166            // In that case there isn't a main ParserOutput object to represent the page or output.
2167            // But, the skin and frontend code mostly don't make this distinction, and so we still
2168            // need to return something for mw.config.
2169            //
2170            // For historical reasons, the expectation is that:
2171            // * on a SpecialPage, we return the language for the content area just like on a
2172            //   page view. SpecialPage content is localised, and so this is the user language.
2173            // * on an Action about a WikiPage, we return the language that content would have
2174            //   been shown in, if this were a page view. This is generally the page language
2175            //   as stored in the database, except adapted to the current user (e.g. in case of
2176            //   translated pages or a language variant preference)
2177            //
2178            // This mess was centralised to here in 2023 (T341244).
2179            $title = $this->getTitle();
2180            if ( $title->isSpecialPage() ) {
2181                // Special pages render in the interface language, based on request context.
2182                // If the user's preference (or request parameter) specifies a variant,
2183                // the content may have been converted to the user's language variant.
2184                $pageLang = $this->getLanguage();
2185            } else {
2186                wfDebug( __METHOD__ . ' has to guess ParserOutput language' );
2187                // Guess what Article::getParserOutput and ParserOptions::optionsHash() would decide
2188                // on a page view:
2189                //
2190                // - Pages may have a custom page_lang set in the database,
2191                //   via Title::getPageLanguage/Title::getDbPageLanguage
2192                //
2193                // - Interface messages (NS_MEDIAWIKI) render based on their subpage,
2194                //   via Title::getPageLanguage/ContentHandler::getPageLanguage/MessageCache::figureMessage
2195                //
2196                // - Otherwise, pages are assumed to be in the wiki's default content language.
2197                //   via Title::getPageLanguage/ContentHandler::getPageLanguage/MediaWikiServices::getContentLanguage
2198                $pageLang = $title->getPageLanguage();
2199            }
2200            if ( $title->getNamespace() !== NS_MEDIAWIKI ) {
2201                $services = MediaWikiServices::getInstance();
2202                $langConv = $services->getLanguageConverterFactory()->getLanguageConverter( $pageLang );
2203                // NOTE: LanguageConverter::getPreferredVariant inspects global RequestContext.
2204                // This usually returns $pageLang unchanged.
2205                $variant = $langConv->getPreferredVariant();
2206                if ( $pageLang->getCode() !== $variant ) {
2207                    $pageLang = $services->getLanguageFactory()->getLanguage( $variant );
2208                }
2209            }
2210            $this->contentLang = $pageLang;
2211        }
2212        return $this->contentLang;
2213    }
2214
2215    /**
2216     * Add all metadata associated with a ParserOutput object, but without the actual HTML. This
2217     * includes categories, language links, ResourceLoader modules, effects of certain magic words,
2218     * and so on.  It does *not* include section information.
2219     *
2220     * @since 1.24
2221     * @param ParserOutput $parserOutput
2222     */
2223    public function addParserOutputMetadata( ParserOutput $parserOutput ) {
2224        // T301020 This should eventually use the standard "merge ParserOutput"
2225        // function between $parserOutput and $this->metadata.
2226        $this->mLanguageLinks =
2227            array_merge( $this->mLanguageLinks, $parserOutput->getLanguageLinks() );
2228        $this->addCategoryLinks( $parserOutput->getCategoryMap() );
2229
2230        // Parser-generated indicators get wrapped like other parser output.
2231        $wrapClass = $parserOutput->getWrapperDivClass();
2232        $result = [];
2233        foreach ( $parserOutput->getIndicators() as $name => $html ) {
2234            if ( $html !== '' && $wrapClass !== '' ) {
2235                $html = Html::rawElement( 'div', [ 'class' => $wrapClass ], $html );
2236            }
2237            $result[$name] = $html;
2238        }
2239        $this->setIndicators( $result );
2240
2241        $tocData = $parserOutput->getTOCData();
2242        // Do not override existing TOC data if the new one is empty (T307256#8817705)
2243        // TODO: Invent a way to merge TOCs from multiple outputs (T327429)
2244        if ( $tocData !== null && ( $this->tocData === null || count( $tocData->getSections() ) > 0 ) ) {
2245            $this->setTOCData( $tocData );
2246        }
2247
2248        // FIXME: Best practice is for OutputPage to be an accumulator, as
2249        // addParserOutputMetadata() may be called multiple times, but the
2250        // following lines overwrite any previous data.  These should
2251        // be migrated to an injection pattern. (T301020, T300979)
2252        // (Note that OutputPage::getOutputFlag() also contains this
2253        // information, with flags from each $parserOutput all OR'ed together.)
2254        $this->mNewSectionLink = $parserOutput->getNewSection();
2255        $this->mHideNewSectionLink = $parserOutput->getHideNewSection();
2256        $this->mNoGallery = $parserOutput->getNoGallery();
2257
2258        if ( !$parserOutput->isCacheable() ) {
2259            $this->disableClientCache();
2260        }
2261        $this->addHeadItems( $parserOutput->getHeadItems() );
2262        $this->addModules( $parserOutput->getModules() );
2263        $this->addModuleStyles( $parserOutput->getModuleStyles() );
2264        $this->addJsConfigVars( $parserOutput->getJsConfigVars() );
2265        $this->mPreventClickjacking = $this->mPreventClickjacking
2266            || $parserOutput->getPreventClickjacking();
2267        $scriptSrcs = $parserOutput->getExtraCSPScriptSrcs();
2268        foreach ( $scriptSrcs as $src ) {
2269            $this->getCSP()->addScriptSrc( $src );
2270        }
2271        $defaultSrcs = $parserOutput->getExtraCSPDefaultSrcs();
2272        foreach ( $defaultSrcs as $src ) {
2273            $this->getCSP()->addDefaultSrc( $src );
2274        }
2275        $styleSrcs = $parserOutput->getExtraCSPStyleSrcs();
2276        foreach ( $styleSrcs as $src ) {
2277            $this->getCSP()->addStyleSrc( $src );
2278        }
2279
2280        // If $wgImagePreconnect is true, and if the output contains images, give the user-agent
2281        // a hint about a remote hosts from which images may be served. Launched in T123582.
2282        if ( $this->getConfig()->get( MainConfigNames::ImagePreconnect ) && count( $parserOutput->getImages() ) ) {
2283            $preconnect = [];
2284            // Optimization: Instead of processing each image, assume that wikis either serve both
2285            // foreign and local from the same remote hostname (e.g. public wikis at WMF), or that
2286            // foreign images are common enough to be worth the preconnect (e.g. private wikis).
2287            $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
2288            $repoGroup->forEachForeignRepo( static function ( $repo ) use ( &$preconnect ) {
2289                $preconnect[] = $repo->getZoneUrl( 'thumb' );
2290            } );
2291            // Consider both foreign and local repos. While LocalRepo by default uses a relative
2292            // path on the same domain, wiki farms may configure it to use a dedicated hostname.
2293            $preconnect[] = $repoGroup->getLocalRepo()->getZoneUrl( 'thumb' );
2294            foreach ( $preconnect as $url ) {
2295                $host = parse_url( $url, PHP_URL_HOST );
2296                // It is expected that file URLs are often path-only, without hostname (T317329).
2297                if ( $host ) {
2298                    $this->addLink( [ 'rel' => 'preconnect', 'href' => '//' . $host ] );
2299                    break;
2300                }
2301            }
2302        }
2303
2304        // Template versioning...
2305        foreach ( (array)$parserOutput->getTemplateIds() as $ns => $dbks ) {
2306            if ( isset( $this->mTemplateIds[$ns] ) ) {
2307                $this->mTemplateIds[$ns] = $dbks + $this->mTemplateIds[$ns];
2308            } else {
2309                $this->mTemplateIds[$ns] = $dbks;
2310            }
2311        }
2312        // File versioning...
2313        foreach ( (array)$parserOutput->getFileSearchOptions() as $dbk => $data ) {
2314            $this->mImageTimeKeys[$dbk] = $data;
2315        }
2316
2317        // Enable OOUI if requested via ParserOutput
2318        if ( $parserOutput->getEnableOOUI() ) {
2319            $this->enableOOUI();
2320        }
2321
2322        // Include parser limit report
2323        // FIXME: This should append, rather than overwrite, or else this
2324        // data should be injected into the OutputPage like is done for the
2325        // other page-level things (like OutputPage::setTOCData()).
2326        if ( !$this->limitReportJSData ) {
2327            $this->limitReportJSData = $parserOutput->getLimitReportJSData();
2328        }
2329
2330        // Link flags are ignored for now, but may in the future be
2331        // used to mark individual language links.
2332        $linkFlags = [];
2333        $this->getHookRunner()->onLanguageLinks( $this->getTitle(), $this->mLanguageLinks, $linkFlags );
2334
2335        $this->getHookRunner()->onOutputPageParserOutput( $this, $parserOutput );
2336
2337        // This check must be after 'OutputPageParserOutput' runs in addParserOutputMetadata
2338        // so that extensions may modify ParserOutput to toggle TOC.
2339        // This cannot be moved to addParserOutputText because that is not
2340        // called by EditPage for Preview.
2341
2342        // ParserOutputFlags::SHOW_TOC is used to indicate whether the TOC
2343        // should be shown (or hidden) in the output.
2344        $this->mEnableTOC = $this->mEnableTOC ||
2345            $parserOutput->getOutputFlag( ParserOutputFlags::SHOW_TOC );
2346        // Uniform handling of all boolean flags: they are OR'ed together
2347        // (See ParserOutput::collectMetadata())
2348        $flags =
2349            array_flip( $parserOutput->getAllFlags() ) +
2350            array_flip( ParserOutputFlags::cases() );
2351        foreach ( $flags as $name => $ignore ) {
2352            if ( $parserOutput->getOutputFlag( $name ) ) {
2353                $this->mOutputFlags[$name] = true;
2354            }
2355        }
2356    }
2357
2358    private function getParserOutputText( ParserOutput $parserOutput, array $poOptions = [] ): string {
2359        // Add default options from the skin
2360        $skin = $this->getSkin();
2361        $skinOptions = $skin->getOptions();
2362        $poOptions += [
2363            'skin' => $skin,
2364            'injectTOC' => $skinOptions['toc'],
2365        ];
2366        // Note: this path absolutely expects $parserOutput to be mutated by getText, see T353257
2367        return $parserOutput->getText( $poOptions );
2368    }
2369
2370    /**
2371     * Add the HTML and enhancements for it (like ResourceLoader modules) associated with a
2372     * ParserOutput object, without any other metadata.
2373     *
2374     * @since 1.24
2375     * @param ParserOutput $parserOutput
2376     * @param array $poOptions Options to ParserOutput::getText()
2377     */
2378    public function addParserOutputContent( ParserOutput $parserOutput, $poOptions = [] ) {
2379        $text = $this->getParserOutputText( $parserOutput, $poOptions );
2380        $this->addParserOutputText( $text, $poOptions );
2381
2382        $this->addModules( $parserOutput->getModules() );
2383        $this->addModuleStyles( $parserOutput->getModuleStyles() );
2384
2385        $this->addJsConfigVars( $parserOutput->getJsConfigVars() );
2386    }
2387
2388    /**
2389     * Add the HTML associated with a ParserOutput object, without any metadata.
2390     *
2391     * @internal For local use only
2392     * @param string|ParserOutput $text
2393     * @param array $poOptions Options to ParserOutput::getText()
2394     */
2395    public function addParserOutputText( $text, $poOptions = [] ) {
2396        if ( $text instanceof ParserOutput ) {
2397            wfDeprecated( __METHOD__ . ' with ParserOutput as first arg', '1.42' );
2398            $text = $this->getParserOutputText( $text, $poOptions );
2399        }
2400        $this->getHookRunner()->onOutputPageBeforeHTML( $this, $text );
2401        $this->addHTML( $text );
2402    }
2403
2404    /**
2405     * Add everything from a ParserOutput object.
2406     *
2407     * @param ParserOutput $parserOutput
2408     * @param array $poOptions Options to ParserOutput::getText()
2409     */
2410    public function addParserOutput( ParserOutput $parserOutput, $poOptions = [] ) {
2411        $text = $this->getParserOutputText( $parserOutput, $poOptions );
2412        $this->addParserOutputMetadata( $parserOutput );
2413        $this->addParserOutputText( $text, $poOptions );
2414    }
2415
2416    /**
2417     * Add the output of a QuickTemplate to the output buffer
2418     *
2419     * @param \QuickTemplate &$template
2420     */
2421    public function addTemplate( &$template ) {
2422        $this->addHTML( $template->getHTML() );
2423    }
2424
2425    /**
2426     * Parse wikitext *in the page content language* and return the HTML.
2427     * The result will be language-converted to the user's preferred variant.
2428     * Output will be tidy.
2429     *
2430     * @param string $text Wikitext in the page content language
2431     * @param bool $linestart Is this the start of a line? (Defaults to true)
2432     * @return string HTML
2433     * @since 1.32
2434     */
2435    public function parseAsContent( $text, $linestart = true ) {
2436        return $this->parseInternal(
2437            $text, $this->getTitle(), $linestart, /*interface*/false
2438        )->getText( [
2439            'allowTOC' => false,
2440            'enableSectionEditLinks' => false,
2441            'wrapperDivClass' => '',
2442            'userLang' => $this->getContext()->getLanguage(),
2443        ] );
2444    }
2445
2446    /**
2447     * Parse wikitext *in the user interface language* and return the HTML.
2448     * The result will not be language-converted, as user interface messages
2449     * are already localized into a specific variant.
2450     * Output will be tidy.
2451     *
2452     * @param string $text Wikitext in the user interface language
2453     * @param bool $linestart Is this the start of a line? (Defaults to true)
2454     * @return string HTML
2455     * @since 1.32
2456     */
2457    public function parseAsInterface( $text, $linestart = true ) {
2458        return $this->parseInternal(
2459            $text, $this->getTitle(), $linestart, /*interface*/true
2460        )->getText( [
2461            'allowTOC' => false,
2462            'enableSectionEditLinks' => false,
2463            'wrapperDivClass' => '',
2464            'userLang' => $this->getContext()->getLanguage(),
2465        ] );
2466    }
2467
2468    /**
2469     * Parse wikitext *in the user interface language*, strip
2470     * paragraph wrapper, and return the HTML.
2471     * The result will not be language-converted, as user interface messages
2472     * are already localized into a specific variant.
2473     * Output will be tidy.  Outer paragraph wrapper will only be stripped
2474     * if the result is a single paragraph.
2475     *
2476     * @param string $text Wikitext in the user interface language
2477     * @param bool $linestart Is this the start of a line? (Defaults to true)
2478     * @return string HTML
2479     * @since 1.32
2480     */
2481    public function parseInlineAsInterface( $text, $linestart = true ) {
2482        return Parser::stripOuterParagraph(
2483            $this->parseAsInterface( $text, $linestart )
2484        );
2485    }
2486
2487    /**
2488     * Parse wikitext and return the HTML (internal implementation helper)
2489     *
2490     * @param string $text
2491     * @param PageReference $title The title to use
2492     * @param bool $linestart Is this the start of a line?
2493     * @param bool $interface Use interface language (instead of content language) while parsing
2494     *   language sensitive magic words like GRAMMAR and PLURAL.  This also disables
2495     *   LanguageConverter.
2496     * @return ParserOutput
2497     */
2498    private function parseInternal( $text, $title, $linestart, $interface ) {
2499        if ( $title === null ) {
2500            throw new RuntimeException( 'Empty $mTitle in ' . __METHOD__ );
2501        }
2502        $popts = $this->parserOptions();
2503
2504        $oldInterface = $popts->setInterfaceMessage( (bool)$interface );
2505
2506        $parserOutput = MediaWikiServices::getInstance()->getParserFactory()->getInstance()
2507            ->parse(
2508                $text, $title, $popts,
2509                $linestart, true, $this->mRevisionId
2510            );
2511
2512        $popts->setInterfaceMessage( $oldInterface );
2513
2514        return $parserOutput;
2515    }
2516
2517    /**
2518     * Set the value of the "s-maxage" part of the "Cache-control" HTTP header
2519     *
2520     * @param int $maxage Maximum cache time on the CDN, in seconds.
2521     */
2522    public function setCdnMaxage( $maxage ) {
2523        $this->mCdnMaxage = min( $maxage, $this->mCdnMaxageLimit );
2524    }
2525
2526    /**
2527     * Set the value of the "s-maxage" part of the "Cache-control" HTTP header to $maxage if that is
2528     * lower than the current s-maxage.  Either way, $maxage is now an upper limit on s-maxage, so
2529     * that future calls to setCdnMaxage() will no longer be able to raise the s-maxage above
2530     * $maxage.
2531     *
2532     * @param int $maxage Maximum cache time on the CDN, in seconds
2533     * @since 1.27
2534     */
2535    public function lowerCdnMaxage( $maxage ) {
2536        $this->mCdnMaxageLimit = min( $maxage, $this->mCdnMaxageLimit );
2537        $this->setCdnMaxage( $this->mCdnMaxage );
2538    }
2539
2540    /**
2541     * Get TTL in [$minTTL,$maxTTL] and pass it to lowerCdnMaxage()
2542     *
2543     * This sets and returns $minTTL if $mtime is false or null. Otherwise,
2544     * the TTL is higher the older the $mtime timestamp is. Essentially, the
2545     * TTL is 90% of the age of the object, subject to the min and max.
2546     *
2547     * @param string|int|float|bool|null $mtime Last-Modified timestamp
2548     * @param int $minTTL Minimum TTL in seconds [default: 1 minute]
2549     * @param int $maxTTL Maximum TTL in seconds [default: $wgCdnMaxAge]
2550     * @since 1.28
2551     */
2552    public function adaptCdnTTL( $mtime, $minTTL = 0, $maxTTL = 0 ) {
2553        $minTTL = $minTTL ?: ExpirationAwareness::TTL_MINUTE;
2554        $maxTTL = $maxTTL ?: $this->getConfig()->get( MainConfigNames::CdnMaxAge );
2555
2556        if ( $mtime === null || $mtime === false ) {
2557            return; // entity does not exist
2558        }
2559
2560        $age = MWTimestamp::time() - (int)wfTimestamp( TS_UNIX, $mtime );
2561        $adaptiveTTL = max( 0.9 * $age, $minTTL );
2562        $adaptiveTTL = min( $adaptiveTTL, $maxTTL );
2563
2564        $this->lowerCdnMaxage( (int)$adaptiveTTL );
2565    }
2566
2567    /**
2568     * Do not send nocache headers
2569     */
2570    public function enableClientCache(): void {
2571        $this->mEnableClientCache = true;
2572    }
2573
2574    /**
2575     * Force the page to send nocache headers
2576     * @since 1.38
2577     */
2578    public function disableClientCache(): void {
2579        $this->mEnableClientCache = false;
2580    }
2581
2582    /**
2583     * Whether the output might become publicly cached.
2584     *
2585     * @since 1.34
2586     * @return bool
2587     */
2588    public function couldBePublicCached() {
2589        if ( !$this->cacheIsFinal ) {
2590            // - The entry point handles its own caching and/or doesn't use OutputPage.
2591            //   (such as load.php, or MediaWiki\Rest\EntryPoint).
2592            //
2593            // - Or, we haven't finished processing the main part of the request yet
2594            //   (e.g. Action::show, SpecialPage::execute), and the state may still
2595            //   change via enableClientCache().
2596            return true;
2597        }
2598        // e.g. various error-type pages disable all client caching
2599        return $this->mEnableClientCache;
2600    }
2601
2602    /**
2603     * Set the expectation that cache control will not change after this point.
2604     *
2605     * This should be called after the main processing logic has completed
2606     * (e.g. Action::show or SpecialPage::execute), but may be called
2607     * before Skin output has started (OutputPage::output).
2608     *
2609     * @since 1.34
2610     */
2611    public function considerCacheSettingsFinal() {
2612        $this->cacheIsFinal = true;
2613    }
2614
2615    /**
2616     * Get the list of cookie names that will influence the cache
2617     *
2618     * @return array
2619     */
2620    public function getCacheVaryCookies() {
2621        if ( self::$cacheVaryCookies === null ) {
2622            $config = $this->getConfig();
2623            self::$cacheVaryCookies = array_values( array_unique( array_merge(
2624                SessionManager::singleton()->getVaryCookies(),
2625                [
2626                    'forceHTTPS',
2627                ],
2628                $config->get( MainConfigNames::CacheVaryCookies )
2629            ) ) );
2630            $this->getHookRunner()->onGetCacheVaryCookies( $this, self::$cacheVaryCookies );
2631        }
2632        return self::$cacheVaryCookies;
2633    }
2634
2635    /**
2636     * Check if the request has a cache-varying cookie header
2637     * If it does, it's very important that we don't allow public caching
2638     *
2639     * @return bool
2640     */
2641    public function haveCacheVaryCookies() {
2642        $request = $this->getRequest();
2643        foreach ( $this->getCacheVaryCookies() as $cookieName ) {
2644            if ( $request->getCookie( $cookieName, '', '' ) !== '' ) {
2645                wfDebug( __METHOD__ . ": found $cookieName" );
2646                return true;
2647            }
2648        }
2649        wfDebug( __METHOD__ . ": no cache-varying cookies found" );
2650        return false;
2651    }
2652
2653    /**
2654     * Add an HTTP header that will have an influence on the cache
2655     *
2656     * @param string $header Header name
2657     */
2658    public function addVaryHeader( $header ) {
2659        if ( !array_key_exists( $header, $this->mVaryHeader ) ) {
2660            $this->mVaryHeader[$header] = null;
2661        }
2662    }
2663
2664    /**
2665     * Return a Vary: header on which to vary caches. Based on the keys of $mVaryHeader,
2666     * such as Accept-Encoding or Cookie
2667     *
2668     * @return string
2669     */
2670    public function getVaryHeader() {
2671        // If we vary on cookies, let's make sure it's always included here too.
2672        if ( $this->getCacheVaryCookies() ) {
2673            $this->addVaryHeader( 'Cookie' );
2674        }
2675
2676        foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $_ ) {
2677            $this->addVaryHeader( $header );
2678        }
2679        return 'Vary: ' . implode( ', ', array_keys( $this->mVaryHeader ) );
2680    }
2681
2682    /**
2683     * Add an HTTP Link: header
2684     *
2685     * @param string $header Header value
2686     */
2687    public function addLinkHeader( $header ) {
2688        $this->mLinkHeader[] = $header;
2689    }
2690
2691    /**
2692     * Return a Link: header. Based on the values of $mLinkHeader.
2693     *
2694     * @return string|false
2695     */
2696    public function getLinkHeader() {
2697        if ( !$this->mLinkHeader ) {
2698            return false;
2699        }
2700
2701        return 'Link: ' . implode( ',', $this->mLinkHeader );
2702    }
2703
2704    /**
2705     * T23672: Add Accept-Language to Vary header if there's no 'variant' parameter in GET.
2706     *
2707     * For example:
2708     *   /w/index.php?title=Main_page will vary based on Accept-Language; but
2709     *   /w/index.php?title=Main_page&variant=zh-cn will not.
2710     */
2711    private function addAcceptLanguage() {
2712        $title = $this->getTitle();
2713        if ( !$title instanceof Title ) {
2714            return;
2715        }
2716
2717        $languageConverter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
2718            ->getLanguageConverter( $title->getPageLanguage() );
2719        if ( !$this->getRequest()->getCheck( 'variant' ) && $languageConverter->hasVariants() ) {
2720            $this->addVaryHeader( 'Accept-Language' );
2721        }
2722    }
2723
2724    /**
2725     * Set the prevent-clickjacking flag.
2726     *
2727     * If true, will cause an X-Frame-Options header appropriate for
2728     * edit pages to be sent. The header value is controlled by
2729     * $wgEditPageFrameOptions.  This is the default for special
2730     * pages. If you display a CSRF-protected form on an ordinary view
2731     * page, then you need to call this function.
2732     *
2733     * Setting this flag to false will turn off frame-breaking.  This
2734     * can be called from pages which do not contain any
2735     * CSRF-protected HTML form.
2736     *
2737     * @param bool $enable If true, will cause an X-Frame-Options header
2738     *  appropriate for edit pages to be sent.
2739     *
2740     * @since 1.38
2741     */
2742    public function setPreventClickjacking( bool $enable ) {
2743        $this->mPreventClickjacking = $enable;
2744    }
2745
2746    /**
2747     * Get the prevent-clickjacking flag
2748     *
2749     * @since 1.24
2750     * @return bool
2751     */
2752    public function getPreventClickjacking() {
2753        return $this->mPreventClickjacking;
2754    }
2755
2756    /**
2757     * Get the X-Frame-Options header value (without the name part), or false
2758     * if there isn't one. This is used by Skin to determine whether to enable
2759     * JavaScript frame-breaking, for clients that don't support X-Frame-Options.
2760     *
2761     * @return string|false
2762     */
2763    public function getFrameOptions() {
2764        $config = $this->getConfig();
2765        if ( $config->get( MainConfigNames::BreakFrames ) ) {
2766            return 'DENY';
2767        } elseif ( $this->mPreventClickjacking && $config->get( MainConfigNames::EditPageFrameOptions ) ) {
2768            return $config->get( MainConfigNames::EditPageFrameOptions );
2769        }
2770        return false;
2771    }
2772
2773    private function getReportTo() {
2774        $config = $this->getConfig();
2775
2776        $expiry = $config->get( MainConfigNames::ReportToExpiry );
2777
2778        if ( !$expiry ) {
2779            return false;
2780        }
2781
2782        $endpoints = $config->get( MainConfigNames::ReportToEndpoints );
2783
2784        if ( !$endpoints ) {
2785            return false;
2786        }
2787
2788        $output = [ 'max_age' => $expiry, 'endpoints' => [] ];
2789
2790        foreach ( $endpoints as $endpoint ) {
2791            $output['endpoints'][] = [ 'url' => $endpoint ];
2792        }
2793
2794        return json_encode( $output, JSON_UNESCAPED_SLASHES );
2795    }
2796
2797    private function getFeaturePolicyReportOnly() {
2798        $config = $this->getConfig();
2799
2800        $features = $config->get( MainConfigNames::FeaturePolicyReportOnly );
2801        return implode( ';', $features );
2802    }
2803
2804    /**
2805     * Send cache control HTTP headers
2806     */
2807    public function sendCacheControl() {
2808        $response = $this->getRequest()->response();
2809        $config = $this->getConfig();
2810
2811        $this->addVaryHeader( 'Cookie' );
2812        $this->addAcceptLanguage();
2813
2814        # don't serve compressed data to clients who can't handle it
2815        # maintain different caches for logged-in users and non-logged in ones
2816        $response->header( $this->getVaryHeader() );
2817
2818        if ( $this->mEnableClientCache ) {
2819            if ( !$config->get( MainConfigNames::UseCdn ) ) {
2820                $privateReason = 'config';
2821            } elseif ( $response->hasCookies() ) {
2822                $privateReason = 'set-cookies';
2823            // The client might use methods other than cookies to appear logged-in.
2824            // E.g. HTTP headers, or query parameter tokens, OAuth, etc.
2825            } elseif ( SessionManager::getGlobalSession()->isPersistent() ) {
2826                $privateReason = 'session';
2827            } elseif ( $this->isPrintable() ) {
2828                $privateReason = 'printable';
2829            } elseif ( $this->mCdnMaxage == 0 ) {
2830                $privateReason = 'no-maxage';
2831            } elseif ( $this->haveCacheVaryCookies() ) {
2832                $privateReason = 'cache-vary-cookies';
2833            } else {
2834                $privateReason = false;
2835            }
2836
2837            if ( $privateReason === false ) {
2838                # We'll purge the proxy cache for anons explicitly, but require end user agents
2839                # to revalidate against the proxy on each visit.
2840                # IMPORTANT! The CDN needs to replace the Cache-Control header with
2841                # Cache-Control: s-maxage=0, must-revalidate, max-age=0
2842                wfDebug( __METHOD__ .
2843                    ": local proxy caching; {$this->mLastModified} **", 'private' );
2844                # start with a shorter timeout for initial testing
2845                # header( "Cache-Control: s-maxage=2678400, must-revalidate, max-age=0" );
2846                $response->header( "Cache-Control: " .
2847                    "s-maxage={$this->mCdnMaxage}, must-revalidate, max-age=0" );
2848            } else {
2849                # We do want clients to cache if they can, but they *must* check for updates
2850                # on revisiting the page.
2851                wfDebug( __METHOD__ . ": private caching ($privateReason); {$this->mLastModified} **", 'private' );
2852
2853                $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
2854                $response->header( "Cache-Control: private, must-revalidate, max-age=0" );
2855            }
2856            if ( $this->mLastModified ) {
2857                $response->header( "Last-Modified: {$this->mLastModified}" );
2858            }
2859        } else {
2860            wfDebug( __METHOD__ . ": no caching **", 'private' );
2861
2862            # In general, the absence of a last modified header should be enough to prevent
2863            # the client from using its cache. We send a few other things just to make sure.
2864            $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
2865            $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
2866        }
2867    }
2868
2869    /**
2870     * Transfer styles and JavaScript modules from skin.
2871     *
2872     * @param Skin $sk to load modules for
2873     */
2874    public function loadSkinModules( $sk ) {
2875        foreach ( $sk->getDefaultModules() as $group => $modules ) {
2876            if ( $group === 'styles' ) {
2877                foreach ( $modules as $moduleMembers ) {
2878                    $this->addModuleStyles( $moduleMembers );
2879                }
2880            } else {
2881                $this->addModules( $modules );
2882            }
2883        }
2884    }
2885
2886    /**
2887     * Finally, all the text has been munged and accumulated into
2888     * the object, let's actually output it:
2889     *
2890     * @param bool $return Set to true to get the result as a string rather than sending it
2891     * @return string|null
2892     */
2893    public function output( $return = false ) {
2894        if ( $this->mDoNothing ) {
2895            return $return ? '' : null;
2896        }
2897
2898        $response = $this->getRequest()->response();
2899        $config = $this->getConfig();
2900
2901        if ( $this->mRedirect != '' ) {
2902            $services = MediaWikiServices::getInstance();
2903            // Modern standards don't require redirect URLs to be absolute, but make it so just in case.
2904            // Note that this doesn't actually guarantee an absolute URL: relative-path URLs are left intact.
2905            $this->mRedirect = (string)$services->getUrlUtils()->expand( $this->mRedirect, PROTO_CURRENT );
2906
2907            $redirect = $this->mRedirect;
2908            $code = $this->mRedirectCode;
2909            $content = '';
2910
2911            if ( $this->getHookRunner()->onBeforePageRedirect( $this, $redirect, $code ) ) {
2912                if ( $code == '301' || $code == '303' ) {
2913                    if ( !$config->get( MainConfigNames::DebugRedirects ) ) {
2914                        $response->statusHeader( (int)$code );
2915                    }
2916                    $this->mLastModified = wfTimestamp( TS_RFC2822 );
2917                }
2918                if ( $config->get( MainConfigNames::VaryOnXFP ) ) {
2919                    $this->addVaryHeader( 'X-Forwarded-Proto' );
2920                }
2921                $this->sendCacheControl();
2922
2923                $response->header( 'Content-Type: text/html; charset=UTF-8' );
2924                if ( $config->get( MainConfigNames::DebugRedirects ) ) {
2925                    $url = htmlspecialchars( $redirect );
2926                    $content = "<!DOCTYPE html>\n<html>\n<head>\n"
2927                        . "<title>Redirect</title>\n</head>\n<body>\n"
2928                        . "<p>Location: <a href=\"$url\">$url</a></p>\n"
2929                        . "</body>\n</html>\n";
2930
2931                    if ( !$return ) {
2932                        print $content;
2933                    }
2934
2935                } else {
2936                    $response->header( 'Location: ' . $redirect );
2937                }
2938            }
2939
2940            return $return ? $content : null;
2941        } elseif ( $this->mStatusCode ) {
2942            $response->statusHeader( $this->mStatusCode );
2943        }
2944
2945        # Buffer output; final headers may depend on later processing
2946        ob_start();
2947
2948        $response->header( 'Content-type: ' . $config->get( MainConfigNames::MimeType ) . '; charset=UTF-8' );
2949        $response->header( 'Content-language: ' .
2950            MediaWikiServices::getInstance()->getContentLanguage()->getHtmlCode() );
2951
2952        $linkHeader = $this->getLinkHeader();
2953        if ( $linkHeader ) {
2954            $response->header( $linkHeader );
2955        }
2956
2957        // Prevent framing, if requested
2958        $frameOptions = $this->getFrameOptions();
2959        if ( $frameOptions ) {
2960            $response->header( "X-Frame-Options: $frameOptions" );
2961        }
2962
2963        // Get the Origin-Trial header values. This is used to enable Chrome Origin
2964        // Trials: https://github.com/GoogleChrome/OriginTrials
2965        $originTrials = $config->get( MainConfigNames::OriginTrials );
2966        foreach ( $originTrials as $originTrial ) {
2967            $response->header( "Origin-Trial: $originTrial", false );
2968        }
2969
2970        $reportTo = $this->getReportTo();
2971        if ( $reportTo ) {
2972            $response->header( "Report-To: $reportTo" );
2973        }
2974
2975        $featurePolicyReportOnly = $this->getFeaturePolicyReportOnly();
2976        if ( $featurePolicyReportOnly ) {
2977            $response->header( "Feature-Policy-Report-Only: $featurePolicyReportOnly" );
2978        }
2979
2980        if ( $this->mArticleBodyOnly ) {
2981            if ( $this->cspOutputMode === self::CSP_HEADERS ) {
2982                $this->CSP->sendHeaders();
2983            }
2984            echo $this->mBodytext;
2985        } else {
2986            // Enable safe mode if requested (T152169)
2987            if ( $this->getRequest()->getBool( 'safemode' ) ) {
2988                $this->disallowUserJs();
2989            }
2990
2991            $sk = $this->getSkin();
2992            $this->loadSkinModules( $sk );
2993
2994            MWDebug::addModules( $this );
2995
2996            // Hook that allows last minute changes to the output page, e.g.
2997            // adding of CSS or Javascript by extensions, adding CSP sources.
2998            $this->getHookRunner()->onBeforePageDisplay( $this, $sk );
2999
3000            if ( $this->cspOutputMode === self::CSP_HEADERS ) {
3001                $this->CSP->sendHeaders();
3002            }
3003
3004            try {
3005                $sk->outputPage();
3006            } catch ( Exception $e ) {
3007                ob_end_clean(); // bug T129657
3008                throw $e;
3009            }
3010        }
3011
3012        try {
3013            // This hook allows last minute changes to final overall output by modifying output buffer
3014            $this->getHookRunner()->onAfterFinalPageOutput( $this );
3015        } catch ( Exception $e ) {
3016            ob_end_clean(); // bug T129657
3017            throw $e;
3018        }
3019
3020        $this->sendCacheControl();
3021
3022        if ( $return ) {
3023            return ob_get_clean();
3024        } else {
3025            ob_end_flush();
3026            return null;
3027        }
3028    }
3029
3030    /**
3031     * Prepare this object to display an error page; disable caching and
3032     * indexing, clear the current text and redirect, set the page's title
3033     * and optionally a custom HTML title (content of the "<title>" tag).
3034     *
3035     * @param string|Message|null $pageTitle Will be passed directly to setPageTitle()
3036     * @param string|Message|false $htmlTitle Will be passed directly to setHTMLTitle();
3037     *                   optional, if not passed the "<title>" attribute will be
3038     *                   based on $pageTitle
3039     * @note Explicitly passing $pageTitle or $htmlTitle has been deprecated
3040     *   since 1.41; use ::setPageTitleMsg() and ::setHTMLTitle() instead.
3041     */
3042    public function prepareErrorPage( $pageTitle = null, $htmlTitle = false ) {
3043        if ( $pageTitle !== null || $htmlTitle !== false ) {
3044            wfDeprecated( __METHOD__ . ' with explicit arguments', '1.41' );
3045            if ( $pageTitle !== null ) {
3046                $this->setPageTitle( $pageTitle );
3047            }
3048            if ( $htmlTitle !== false ) {
3049                $this->setHTMLTitle( $htmlTitle );
3050            }
3051        }
3052        $this->setRobotPolicy( 'noindex,nofollow' );
3053        $this->setArticleRelated( false );
3054        $this->disableClientCache();
3055        $this->mRedirect = '';
3056        $this->clearSubtitle();
3057        $this->clearHTML();
3058    }
3059
3060    /**
3061     * Output a standard error page
3062     *
3063     * showErrorPage( 'titlemsg', 'pagetextmsg' );
3064     * showErrorPage( 'titlemsg', 'pagetextmsg', [ 'param1', 'param2' ] );
3065     * showErrorPage( 'titlemsg', $messageObject );
3066     * showErrorPage( $titleMessageObject, $messageObject );
3067     *
3068     * @param string|MessageSpecifier $title Message key (string) for page title, or a MessageSpecifier
3069     * @param string|MessageSpecifier $msg Message key (string) for page text, or a MessageSpecifier
3070     * @param array $params Message parameters; ignored if $msg is a Message object
3071     * @param PageReference|LinkTarget|string|null $returnto Page to show a return link to;
3072     *   defaults to the 'returnto' URL parameter
3073     * @param string|null $returntoquery Query string for the return to link;
3074     *   defaults to the 'returntoquery' URL parameter
3075     */
3076    public function showErrorPage(
3077        $title, $msg, $params = [], $returnto = null, $returntoquery = null
3078    ) {
3079        if ( !$title instanceof Message ) {
3080            $title = $this->msg( $title );
3081        }
3082
3083        $this->prepareErrorPage();
3084        $this->setPageTitleMsg( $title );
3085
3086        if ( $msg instanceof Message ) {
3087            if ( $params !== [] ) {
3088                trigger_error( 'Argument ignored: $params. The message parameters argument '
3089                    . 'is discarded when the $msg argument is a Message object instead of '
3090                    . 'a string.', E_USER_NOTICE );
3091            }
3092            $this->addHTML( $msg->parseAsBlock() );
3093        } else {
3094            $this->addWikiMsgArray( $msg, $params );
3095        }
3096
3097        $this->returnToMain( null, $returnto, $returntoquery );
3098    }
3099
3100    /**
3101     * Output a standard permission error page
3102     *
3103     * @param array $errors Error message keys or [key, param...] arrays
3104     * @param string|null $action Action that was denied or null if unknown
3105     */
3106    public function showPermissionsErrorPage( array $errors, $action = null ) {
3107        $services = MediaWikiServices::getInstance();
3108        $groupPermissionsLookup = $services->getGroupPermissionsLookup();
3109        foreach ( $errors as $key => $error ) {
3110            $errors[$key] = (array)$error;
3111        }
3112
3113        // For some actions (read, edit, create and upload), display a "login to do this action"
3114        // error if all of the following conditions are met:
3115        // 1. the user is not logged in as a named user, and so cannot be added to groups
3116        // 2. the only error is insufficient permissions (i.e. no block or something else)
3117        // 3. the error can be avoided simply by logging in
3118
3119        if ( in_array( $action, [ 'read', 'edit', 'createpage', 'createtalk', 'upload' ] )
3120            && !$this->getUser()->isNamed() && count( $errors ) == 1 && isset( $errors[0][0] )
3121            && ( $errors[0][0] == 'badaccess-groups' || $errors[0][0] == 'badaccess-group0' )
3122            && ( $groupPermissionsLookup->groupHasPermission( 'user', $action )
3123                || $groupPermissionsLookup->groupHasPermission( 'autoconfirmed', $action ) )
3124        ) {
3125            $displayReturnto = null;
3126
3127            # Due to T34276, if a user does not have read permissions,
3128            # $this->getTitle() will just give Special:Badtitle, which is
3129            # not especially useful as a returnto parameter. Use the title
3130            # from the request instead, if there was one.
3131            $request = $this->getRequest();
3132            $returnto = Title::newFromText( $request->getText( 'title' ) );
3133            if ( $action == 'edit' ) {
3134                $msg = 'whitelistedittext';
3135                $displayReturnto = $returnto;
3136            } elseif ( $action == 'createpage' || $action == 'createtalk' ) {
3137                $msg = 'nocreatetext';
3138            } elseif ( $action == 'upload' ) {
3139                $msg = 'uploadnologintext';
3140            } else { # Read
3141                $msg = 'loginreqpagetext';
3142                $displayReturnto = Title::newMainPage();
3143            }
3144
3145            $query = [];
3146
3147            if ( $returnto ) {
3148                $query['returnto'] = $returnto->getPrefixedText();
3149
3150                if ( !$request->wasPosted() ) {
3151                    $returntoquery = $request->getValues();
3152                    unset( $returntoquery['title'] );
3153                    unset( $returntoquery['returnto'] );
3154                    unset( $returntoquery['returntoquery'] );
3155                    $query['returntoquery'] = wfArrayToCgi( $returntoquery );
3156                }
3157            }
3158
3159            $title = SpecialPage::getTitleFor( 'Userlogin' );
3160            $linkRenderer = $services->getLinkRenderer();
3161            $loginUrl = $title->getLinkURL( $query, false, PROTO_RELATIVE );
3162            $loginLink = $linkRenderer->makeKnownLink(
3163                $title,
3164                $this->msg( 'loginreqlink' )->text(),
3165                [],
3166                $query
3167            );
3168
3169            $this->prepareErrorPage();
3170            $this->setPageTitleMsg( $this->msg( 'loginreqtitle' ) );
3171            $this->addHTML( $this->msg( $msg )->rawParams( $loginLink )->params( $loginUrl )->parse() );
3172
3173            # Don't return to a page the user can't read otherwise
3174            # we'll end up in a pointless loop
3175            if ( $displayReturnto && $this->getAuthority()->probablyCan( 'read', $displayReturnto ) ) {
3176                $this->returnToMain( null, $displayReturnto );
3177            }
3178        } else {
3179            $this->prepareErrorPage();
3180            $this->setPageTitleMsg( $this->msg( 'permissionserrors' ) );
3181            $this->addWikiTextAsInterface( $this->formatPermissionsErrorMessage( $errors, $action ) );
3182        }
3183    }
3184
3185    /**
3186     * Display an error page indicating that a given version of MediaWiki is
3187     * required to use it
3188     *
3189     * @param mixed $version The version of MediaWiki needed to use the page
3190     */
3191    public function versionRequired( $version ) {
3192        $this->prepareErrorPage();
3193        $this->setPageTitleMsg(
3194            $this->msg( 'versionrequired' )->plaintextParams( $version )
3195        );
3196
3197        $this->addWikiMsg( 'versionrequiredtext', $version );
3198        $this->returnToMain();
3199    }
3200
3201    /**
3202     * Format permission $status obtained from Authority for display.
3203     *
3204     * @param PermissionStatus $status
3205     * @param string|null $action that was denied or null if unknown
3206     * @return string
3207     */
3208    public function formatPermissionStatus( PermissionStatus $status, string $action = null ): string {
3209        if ( $status->isGood() ) {
3210            return '';
3211        }
3212        return $this->formatPermissionsErrorMessage( $status->toLegacyErrorArray(), $action );
3213    }
3214
3215    /**
3216     * Format a list of error messages
3217     *
3218     * @deprecated since 1.36. Use ::formatPermissionStatus instead
3219     * @param array $errors Array of arrays returned by PermissionManager::getPermissionErrors
3220     * @phan-param non-empty-array[] $errors
3221     * @param string|null $action Action that was denied or null if unknown
3222     * @return string The wikitext error-messages, formatted into a list.
3223     */
3224    public function formatPermissionsErrorMessage( array $errors, $action = null ) {
3225        if ( $action == null ) {
3226            $text = $this->msg( 'permissionserrorstext', count( $errors ) )->plain() . "\n\n";
3227        } else {
3228            $action_desc = $this->msg( "action-$action" )->plain();
3229            $text = $this->msg(
3230                'permissionserrorstext-withaction',
3231                count( $errors ),
3232                $action_desc
3233            )->plain() . "\n\n";
3234        }
3235
3236        if ( count( $errors ) > 1 ) {
3237            $text .= '<ul class="permissions-errors">' . "\n";
3238
3239            foreach ( $errors as $error ) {
3240                $text .= '<li>';
3241                $text .= $this->msg( ...$error )->plain();
3242                $text .= "</li>\n";
3243            }
3244            $text .= '</ul>';
3245        } else {
3246            $text .= "<div class=\"permissions-errors\">\n" .
3247                    // @phan-suppress-next-line PhanParamTooFewUnpack Elements of $errors already annotated as non-empty
3248                    $this->msg( ...reset( $errors ) )->plain() .
3249                    "\n</div>";
3250        }
3251
3252        return $text;
3253    }
3254
3255    /**
3256     * Show a warning about replica DB lag
3257     *
3258     * If the lag is higher than $wgDatabaseReplicaLagCritical seconds,
3259     * then the warning is a bit more obvious. If the lag is
3260     * lower than $wgDatabaseReplicaLagWarning, then no warning is shown.
3261     *
3262     * @param int $lag Replica lag
3263     */
3264    public function showLagWarning( $lag ) {
3265        $config = $this->getConfig();
3266        if ( $lag >= $config->get( MainConfigNames::DatabaseReplicaLagWarning ) ) {
3267            $lag = floor( $lag ); // floor to avoid nano seconds to display
3268            $message = $lag < $config->get( MainConfigNames::DatabaseReplicaLagCritical )
3269                ? 'lag-warn-normal'
3270                : 'lag-warn-high';
3271            // For grep: mw-lag-warn-normal, mw-lag-warn-high
3272            $wrap = Html::rawElement( 'div', [ 'class' => "mw-{$message}" ], "\n$1\n" );
3273            $this->wrapWikiMsg( "$wrap\n", [ $message, $this->getLanguage()->formatNum( $lag ) ] );
3274        }
3275    }
3276
3277    /**
3278     * Output an error page
3279     *
3280     * @deprecated since 1.43 Use showErrorPage() instead
3281     * @param string $message Error to output. Must be escaped for HTML.
3282     */
3283    public function showFatalError( $message ) {
3284        wfDeprecated( __METHOD__, '1.43' );
3285
3286        $this->prepareErrorPage();
3287        $this->setPageTitleMsg( $this->msg( 'internalerror' ) );
3288
3289        $this->addHTML( $message );
3290    }
3291
3292    /**
3293     * Add a "return to" link pointing to a specified title
3294     *
3295     * @param LinkTarget $title Title to link
3296     * @param array $query Query string parameters
3297     * @param string|null $text Text of the link (input is not escaped)
3298     * @param array $options Options array to pass to Linker
3299     */
3300    public function addReturnTo( $title, array $query = [], $text = null, $options = [] ) {
3301        $linkRenderer = MediaWikiServices::getInstance()
3302            ->getLinkRendererFactory()->createFromLegacyOptions( $options );
3303        $link = $this->msg( 'returnto' )->rawParams(
3304            $linkRenderer->makeLink( $title, $text, [], $query ) )->escaped();
3305        $this->addHTML( "<p id=\"mw-returnto\">{$link}</p>\n" );
3306    }
3307
3308    /**
3309     * Add a "return to" link pointing to a specified title,
3310     * or the title indicated in the request, or else the main page
3311     *
3312     * @param mixed|null $unused
3313     * @param PageReference|LinkTarget|string|null $returnto Page to return to
3314     * @param string|null $returntoquery Query string for the return to link
3315     */
3316    public function returnToMain( $unused = null, $returnto = null, $returntoquery = null ) {
3317        $returnto ??= $this->getRequest()->getText( 'returnto' );
3318
3319        $returntoquery ??= $this->getRequest()->getText( 'returntoquery' );
3320
3321        if ( $returnto === '' ) {
3322            $returnto = Title::newMainPage();
3323        }
3324
3325        if ( is_object( $returnto ) ) {
3326            $linkTarget = TitleValue::castPageToLinkTarget( $returnto );
3327        } else {
3328            $linkTarget = Title::newFromText( $returnto );
3329        }
3330
3331        // We don't want people to return to external interwiki. That
3332        // might potentially be used as part of a phishing scheme
3333        if ( !is_object( $linkTarget ) || $linkTarget->isExternal() ) {
3334            $linkTarget = Title::newMainPage();
3335        }
3336
3337        $this->addReturnTo( $linkTarget, wfCgiToArray( $returntoquery ) );
3338    }
3339
3340    /**
3341     * Output a standard "wait for takeover" warning
3342     *
3343     * This is useful for extensions which are hooking an action and
3344     * suppressing its normal output so it can be taken over with JS.
3345     *
3346     * showPendingTakeover( 'url', 'pagetextmsg' );
3347     * showPendingTakeover( 'url', 'pagetextmsg', [ 'param1', 'param2' ] );
3348     * showPendingTakeover( 'url', $messageObject );
3349     *
3350     * @param string $fallbackUrl URL to redirect to if the user doesn't have JavaScript
3351     *  or ResourceLoader available; this should ideally be to a page that provides similar
3352     *  functionality without requiring JavaScript
3353     * @param string|MessageSpecifier $msg Message key (string) for page text, or a MessageSpecifier
3354     * @param mixed ...$params Message parameters; ignored if $msg is a Message object
3355     */
3356    public function showPendingTakeover(
3357        $fallbackUrl, $msg, ...$params
3358    ) {
3359        if ( $msg instanceof Message ) {
3360            if ( $params !== [] ) {
3361                trigger_error( 'Argument ignored: $params. The message parameters argument '
3362                    . 'is discarded when the $msg argument is a Message object instead of '
3363                    . 'a string.', E_USER_NOTICE );
3364            }
3365            $this->addHTML( $msg->parseAsBlock() );
3366        } else {
3367            $this->addHTML( $this->msg( $msg, ...$params )->parseAsBlock() );
3368        }
3369
3370        // Redirect if the user has no JS (<noscript>)
3371        $escapedUrl = htmlspecialchars( $fallbackUrl );
3372        $this->addHeadItem(
3373            'mw-noscript-fallback',
3374            // https://html.spec.whatwg.org/#attr-meta-http-equiv-refresh
3375            // means that if $fallbackUrl contains unencoded quotation marks
3376            // then this will behave confusingly, but shouldn't break the page
3377            "<noscript><meta http-equiv=\"refresh\" content=\"0; url=$escapedUrl\"></noscript>"
3378        );
3379        // Redirect if the user has no ResourceLoader
3380        $this->addScript( Html::inlineScript(
3381            "(window.NORLQ=window.NORLQ||[]).push(" .
3382                "function(){" .
3383                    "location.href=" . json_encode( $fallbackUrl ) . ";" .
3384                "}" .
3385            ");"
3386        ) );
3387    }
3388
3389    private function getRlClientContext() {
3390        if ( !$this->rlClientContext ) {
3391            $query = ResourceLoader::makeLoaderQuery(
3392                [], // modules; not relevant
3393                $this->getLanguage()->getCode(),
3394                $this->getSkin()->getSkinName(),
3395                $this->getUser()->isRegistered() ? $this->getUser()->getName() : null,
3396                null, // version; not relevant
3397                ResourceLoader::inDebugMode(),
3398                null, // only; not relevant
3399                $this->isPrintable()
3400            );
3401            $this->rlClientContext = new RL\Context(
3402                $this->getResourceLoader(),
3403                new FauxRequest( $query )
3404            );
3405            if ( $this->contentOverrideCallbacks ) {
3406                $this->rlClientContext = new RL\DerivativeContext( $this->rlClientContext );
3407                $this->rlClientContext->setContentOverrideCallback( function ( $title ) {
3408                    foreach ( $this->contentOverrideCallbacks as $callback ) {
3409                        $content = $callback( $title );
3410                        if ( $content !== null ) {
3411                            $text = ( $content instanceof TextContent ) ? $content->getText() : '';
3412                            if ( strpos( $text, '</script>' ) !== false ) {
3413                                // Proactively replace this so that we can display a message
3414                                // to the user, instead of letting it go to Html::inlineScript(),
3415                                // where it would be considered a server-side issue.
3416                                $content = new JavaScriptContent(
3417                                    Html::encodeJsCall( 'mw.log.error', [
3418                                        "Cannot preview $title due to script-closing tag."
3419                                    ] )
3420                                );
3421                            }
3422                            return $content;
3423                        }
3424                    }
3425                    return null;
3426                } );
3427            }
3428        }
3429        return $this->rlClientContext;
3430    }
3431
3432    /**
3433     * Call this to freeze the module queue and JS config and create a formatter.
3434     *
3435     * Depending on the Skin, this may get lazy-initialised in either headElement() or
3436     * getBottomScripts(). See SkinTemplate::prepareQuickTemplate(). Calling this too early may
3437     * cause unexpected side-effects since disallowUserJs() may be called at any time to change
3438     * the module filters retroactively. Skins and extension hooks may also add modules until very
3439     * late in the request lifecycle.
3440     *
3441     * @return RL\ClientHtml
3442     */
3443    public function getRlClient() {
3444        if ( !$this->rlClient ) {
3445            $context = $this->getRlClientContext();
3446            $rl = $this->getResourceLoader();
3447            $this->addModules( [
3448                'user',
3449                'user.options',
3450            ] );
3451            $this->addModuleStyles( [
3452                'site.styles',
3453                'noscript',
3454                'user.styles',
3455            ] );
3456
3457            // Prepare exempt modules for buildExemptModules()
3458            $exemptGroups = [
3459                RL\Module::GROUP_SITE => [],
3460                RL\Module::GROUP_NOSCRIPT => [],
3461                RL\Module::GROUP_PRIVATE => [],
3462                RL\Module::GROUP_USER => []
3463            ];
3464            $exemptStates = [];
3465            $moduleStyles = $this->getModuleStyles( /*filter*/ true );
3466
3467            // Preload getTitleInfo for isKnownEmpty calls below and in RL\ClientHtml
3468            // Separate user-specific batch for improved cache-hit ratio.
3469            $userBatch = [ 'user.styles', 'user' ];
3470            $siteBatch = array_diff( $moduleStyles, $userBatch );
3471            RL\WikiModule::preloadTitleInfo( $context, $siteBatch );
3472            RL\WikiModule::preloadTitleInfo( $context, $userBatch );
3473
3474            // Filter out modules handled by buildExemptModules()
3475            $moduleStyles = array_filter( $moduleStyles,
3476                static function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) {
3477                    $module = $rl->getModule( $name );
3478                    if ( $module ) {
3479                        $group = $module->getGroup();
3480                        if ( $group !== null && isset( $exemptGroups[$group] ) ) {
3481                            // The `noscript` module is excluded from the client
3482                            // side registry, no need to set its state either.
3483                            // But we still output it. See T291735
3484                            if ( $group !== RL\Module::GROUP_NOSCRIPT ) {
3485                                $exemptStates[$name] = 'ready';
3486                            }
3487                            if ( !$module->isKnownEmpty( $context ) ) {
3488                                // E.g. Don't output empty <styles>
3489                                $exemptGroups[$group][] = $name;
3490                            }
3491                            return false;
3492                        }
3493                    }
3494                    return true;
3495                }
3496            );
3497            $this->rlExemptStyleModules = $exemptGroups;
3498
3499            $config = $this->getConfig();
3500            // Client preferences are controlled by the skin and specific to unregistered
3501            // users. See mw.user.clientPrefs for details on how this works and how to
3502            // handle registered users.
3503            $clientPrefEnabled = (
3504                $this->getSkin()->getOptions()['clientPrefEnabled'] &&
3505                !$this->getUser()->isNamed()
3506            );
3507            $clientPrefCookiePrefix = $config->get( MainConfigNames::CookiePrefix );
3508
3509            $rlClient = new RL\ClientHtml( $context, [
3510                'target' => $this->getTarget(),
3511                // When 'safemode', disallowUserJs(), or reduceAllowedModules() is used
3512                // to only restrict modules to ORIGIN_CORE (ie. disallow ORIGIN_USER), the list of
3513                // modules enqueued for loading on this page is filtered to just those.
3514                // However, to make sure we also apply the restriction to dynamic dependencies and
3515                // lazy-loaded modules at run-time on the client-side, pass 'safemode' down to the
3516                // StartupModule so that the client-side registry will not contain any restricted
3517                // modules either. (T152169, T185303)
3518                'safemode' => ( $this->getAllowedModules( RL\Module::TYPE_COMBINED )
3519                    <= RL\Module::ORIGIN_CORE_INDIVIDUAL
3520                ) ? '1' : null,
3521                'clientPrefEnabled' => $clientPrefEnabled,
3522                'clientPrefCookiePrefix' => $clientPrefCookiePrefix,
3523            ] );
3524            $rlClient->setConfig( $this->getJSVars( self::JS_VAR_EARLY ) );
3525            $rlClient->setModules( $this->getModules( /*filter*/ true ) );
3526            $rlClient->setModuleStyles( $moduleStyles );
3527            $rlClient->setExemptStates( $exemptStates );
3528            $this->rlClient = $rlClient;
3529        }
3530        return $this->rlClient;
3531    }
3532
3533    /**
3534     * @param Skin $sk The given Skin
3535     * @param bool $includeStyle Unused
3536     * @return string The doctype, opening "<html>", and head element.
3537     */
3538    public function headElement( Skin $sk, $includeStyle = true ) {
3539        $config = $this->getConfig();
3540        $userdir = $this->getLanguage()->getDir();
3541        $services = MediaWikiServices::getInstance();
3542        $sitedir = $services->getContentLanguage()->getDir();
3543
3544        $pieces = [];
3545        $htmlAttribs = Sanitizer::mergeAttributes( Sanitizer::mergeAttributes(
3546            $this->getRlClient()->getDocumentAttributes(),
3547            $sk->getHtmlElementAttributes()
3548        ), [ 'class' => implode( ' ', $this->mAdditionalHtmlClasses ) ] );
3549        $pieces[] = Html::htmlHeader( $htmlAttribs );
3550        $pieces[] = Html::openElement( 'head' );
3551
3552        if ( $this->getHTMLTitle() == '' ) {
3553            $this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() );
3554        }
3555
3556        if ( !Html::isXmlMimeType( $config->get( MainConfigNames::MimeType ) ) ) {
3557            // Add <meta charset="UTF-8">
3558            // This should be before <title> since it defines the charset used by
3559            // text including the text inside <title>.
3560            // The spec recommends defining XHTML5's charset using the XML declaration
3561            // instead of meta.
3562            // Our XML declaration is output by Html::htmlHeader.
3563            // https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-content-type
3564            // https://html.spec.whatwg.org/multipage/semantics.html#charset
3565            $pieces[] = Html::element( 'meta', [ 'charset' => 'UTF-8' ] );
3566        }
3567
3568        $pieces[] = Html::element( 'title', [], $this->getHTMLTitle() );
3569        $pieces[] = $this->getRlClient()->getHeadHtml( $htmlAttribs['class'] ?? null );
3570        $pieces[] = $this->buildExemptModules();
3571        $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) );
3572        $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) );
3573
3574        $pieces[] = Html::closeElement( 'head' );
3575
3576        $skinOptions = $sk->getOptions();
3577        $bodyClasses = array_merge( $this->mAdditionalBodyClasses, $skinOptions['bodyClasses'] );
3578        $bodyClasses[] = 'mediawiki';
3579
3580        # Classes for LTR/RTL directionality support
3581        $bodyClasses[] = $userdir;
3582        $bodyClasses[] = "sitedir-$sitedir";
3583
3584        // See Article:showDiffPage for class to support article diff styling
3585
3586        $underline = $services->getUserOptionsLookup()->getOption( $this->getUser(), 'underline' );
3587        if ( $underline < 2 ) {
3588            // The following classes can be used here:
3589            // * mw-underline-always
3590            // * mw-underline-never
3591            $bodyClasses[] = 'mw-underline-' . ( $underline ? 'always' : 'never' );
3592        }
3593
3594        // Parser feature migration class
3595        // The idea is that this will eventually be removed, after the wikitext
3596        // which requires it is cleaned up.
3597        $bodyClasses[] = 'mw-hide-empty-elt';
3598
3599        $bodyClasses[] = $sk->getPageClasses( $this->getTitle() );
3600        $bodyClasses[] = 'skin-' . Sanitizer::escapeClass( $sk->getSkinName() );
3601        $bodyClasses[] =
3602            'action-' . Sanitizer::escapeClass( $this->getContext()->getActionName() );
3603
3604        if ( $sk->isResponsive() ) {
3605            $bodyClasses[] = 'skin--responsive';
3606        }
3607
3608        $bodyAttrs = [];
3609        // While the implode() is not strictly needed, it's used for backwards compatibility
3610        // (this used to be built as a string and hooks likely still expect that).
3611        $bodyAttrs['class'] = implode( ' ', $bodyClasses );
3612
3613        // Allow skins and extensions to add body attributes they need
3614        // Get ones from deprecated method
3615        if ( method_exists( $sk, 'addToBodyAttributes' ) ) {
3616            /** @phan-suppress-next-line PhanUndeclaredMethod */
3617            $sk->addToBodyAttributes( $this, $bodyAttrs );
3618            wfDeprecated( 'Skin::addToBodyAttributes method to add body attributes', '1.35' );
3619        }
3620
3621        // Then run the hook, the recommended way of adding body attributes now
3622        $this->getHookRunner()->onOutputPageBodyAttributes( $this, $sk, $bodyAttrs );
3623
3624        $pieces[] = Html::openElement( 'body', $bodyAttrs );
3625
3626        return self::combineWrappedStrings( $pieces );
3627    }
3628
3629    /**
3630     * Get a ResourceLoader object associated with this OutputPage
3631     *
3632     * @return ResourceLoader
3633     */
3634    public function getResourceLoader() {
3635        if ( $this->mResourceLoader === null ) {
3636            // Lazy-initialise as needed
3637            $this->mResourceLoader = MediaWikiServices::getInstance()->getResourceLoader();
3638        }
3639        return $this->mResourceLoader;
3640    }
3641
3642    /**
3643     * Explicitly load or embed modules on a page.
3644     *
3645     * @param array|string $modules One or more module names
3646     * @param string $only RL\Module TYPE_ class constant
3647     * @param array $extraQuery [optional] Array with extra query parameters for the request
3648     * @return string|WrappedStringList HTML
3649     */
3650    public function makeResourceLoaderLink( $modules, $only, array $extraQuery = [] ) {
3651        // Apply 'origin' filters
3652        $modules = $this->filterModules( (array)$modules, null, $only );
3653
3654        return RL\ClientHtml::makeLoad(
3655            $this->getRlClientContext(),
3656            $modules,
3657            $only,
3658            $extraQuery
3659        );
3660    }
3661
3662    /**
3663     * Combine WrappedString chunks and filter out empty ones
3664     *
3665     * @param array $chunks
3666     * @return string|WrappedStringList HTML
3667     */
3668    protected static function combineWrappedStrings( array $chunks ) {
3669        // Filter out empty values
3670        $chunks = array_filter( $chunks, 'strlen' );
3671        return WrappedString::join( "\n", $chunks );
3672    }
3673
3674    /**
3675     * JS stuff to put at the bottom of the `<body>`.
3676     * These are legacy scripts ($this->mScripts), and user JS.
3677     *
3678     * @return string|WrappedStringList HTML
3679     */
3680    public function getBottomScripts() {
3681        // Keep the hook appendage separate to preserve WrappedString objects.
3682        // This enables to merge them where possible.
3683        $extraHtml = '';
3684        $this->getHookRunner()->onSkinAfterBottomScripts( $this->getSkin(), $extraHtml );
3685
3686        $chunks = [];
3687        $chunks[] = $this->getRlClient()->getBodyHtml();
3688
3689        // Legacy non-ResourceLoader scripts
3690        $chunks[] = $this->mScripts;
3691
3692        // Keep hostname and backend time as the first variables for quick view-source access.
3693        // This other variables will form a very long inline blob.
3694        $vars = [];
3695        if ( $this->getConfig()->get( MainConfigNames::ShowHostnames ) ) {
3696            $vars['wgHostname'] = wfHostname();
3697        }
3698        $elapsed = $this->getRequest()->getElapsedTime();
3699        // seconds to milliseconds
3700        $vars['wgBackendResponseTime'] = round( $elapsed * 1000 );
3701
3702        $vars += $this->getJSVars( self::JS_VAR_LATE );
3703        if ( $this->limitReportJSData ) {
3704            $vars['wgPageParseReport'] = $this->limitReportJSData;
3705        }
3706
3707        $rlContext = $this->getRlClientContext();
3708        $chunks[] = ResourceLoader::makeInlineScript(
3709            'mw.config.set(' . $rlContext->encodeJson( $vars ) . ');'
3710        );
3711
3712        $chunks = [ self::combineWrappedStrings( $chunks ) ];
3713        if ( $extraHtml !== '' ) {
3714            $chunks[] = $extraHtml;
3715        }
3716
3717        return WrappedString::join( "\n", $chunks );
3718    }
3719
3720    /**
3721     * Get the javascript config vars to include on this page
3722     *
3723     * @return array Array of javascript config vars
3724     * @since 1.23
3725     */
3726    public function getJsConfigVars() {
3727        return $this->mJsConfigVars;
3728    }
3729
3730    /**
3731     * Add one or more variables to be set in mw.config in JavaScript
3732     *
3733     * @param string|array $keys Key or array of key/value pairs
3734     * @param mixed|null $value [optional] Value of the configuration variable
3735     */
3736    public function addJsConfigVars( $keys, $value = null ) {
3737        if ( is_array( $keys ) ) {
3738            foreach ( $keys as $key => $value ) {
3739                $this->mJsConfigVars[$key] = $value;
3740            }
3741            return;
3742        }
3743
3744        $this->mJsConfigVars[$keys] = $value;
3745    }
3746
3747    /**
3748     * Get an array containing the variables to be set in mw.config in JavaScript.
3749     *
3750     * Do not add things here which can be evaluated in RL\StartUpModule,
3751     * in other words, page-independent/site-wide variables (without state).
3752     * These would add a blocking HTML cost to page rendering time, and require waiting for
3753     * HTTP caches to expire before configuration changes take effect everywhere.
3754     *
3755     * By default, these are loaded in the HTML head and block page rendering.
3756     * Config variable names can be set in CORE_LATE_JS_CONFIG_VAR_NAMES, or
3757     * for extensions via the 'LateJSConfigVarNames' attribute, to opt-in to
3758     * being sent from the end of the HTML body instead, to improve page load time.
3759     * In JavaScript, late variables should be accessed via mw.hook('wikipage.content').
3760     *
3761     * @param int|null $flag Return only the specified kind of variables: self::JS_VAR_EARLY or self::JS_VAR_LATE.
3762     *   For internal use only.
3763     * @return array
3764     */
3765    public function getJSVars( ?int $flag = null ) {
3766        $curRevisionId = 0;
3767        $articleId = 0;
3768        $canonicalSpecialPageName = false; # T23115
3769        $services = MediaWikiServices::getInstance();
3770
3771        $title = $this->getTitle();
3772        $ns = $title->getNamespace();
3773        $nsInfo = $services->getNamespaceInfo();
3774        $canonicalNamespace = $nsInfo->exists( $ns )
3775            ? $nsInfo->getCanonicalName( $ns )
3776            : $title->getNsText();
3777
3778        $sk = $this->getSkin();
3779        // Get the relevant title so that AJAX features can use the correct page name
3780        // when making API requests from certain special pages (T36972).
3781        $relevantTitle = $sk->getRelevantTitle();
3782
3783        if ( $ns === NS_SPECIAL ) {
3784            [ $canonicalSpecialPageName, /*...*/ ] =
3785                $services->getSpecialPageFactory()->
3786                    resolveAlias( $title->getDBkey() );
3787        } elseif ( $this->canUseWikiPage() ) {
3788            $wikiPage = $this->getWikiPage();
3789            // If we already know that the latest revision ID is the same as the revision ID being viewed,
3790            // avoid fetching it again, as it may give inconsistent results (T339164).
3791            if ( $this->isRevisionCurrent() && $this->getRevisionId() ) {
3792                $curRevisionId = $this->getRevisionId();
3793            } else {
3794                $curRevisionId = $wikiPage->getLatest();
3795            }
3796            $articleId = $wikiPage->getId();
3797        }
3798
3799        // ParserOutput informs HTML/CSS via lang/dir attributes.
3800        // We inform JavaScript via mw.config from here.
3801        $lang = $this->getContentLangForJS();
3802
3803        // Pre-process information
3804        $separatorTransTable = $lang->separatorTransformTable();
3805        $separatorTransTable = $separatorTransTable ?: [];
3806        $compactSeparatorTransTable = [
3807            implode( "\t", array_keys( $separatorTransTable ) ),
3808            implode( "\t", $separatorTransTable ),
3809        ];
3810        $digitTransTable = $lang->digitTransformTable();
3811        $digitTransTable = $digitTransTable ?: [];
3812        $compactDigitTransTable = [
3813            implode( "\t", array_keys( $digitTransTable ) ),
3814            implode( "\t", $digitTransTable ),
3815        ];
3816
3817        $user = $this->getUser();
3818
3819        // Internal variables for MediaWiki core
3820        $vars = [
3821            // @internal For mediawiki.page.ready
3822            'wgBreakFrames' => $this->getFrameOptions() == 'DENY',
3823
3824            // @internal For jquery.tablesorter
3825            'wgSeparatorTransformTable' => $compactSeparatorTransTable,
3826            'wgDigitTransformTable' => $compactDigitTransTable,
3827            'wgDefaultDateFormat' => $lang->getDefaultDateFormat(),
3828            'wgMonthNames' => $lang->getMonthNamesArray(),
3829
3830            // @internal For debugging purposes
3831            'wgRequestId' => WebRequest::getRequestId(),
3832        ];
3833
3834        // Start of supported and stable config vars (for use by extensions/gadgets).
3835        $vars += [
3836            'wgCanonicalNamespace' => $canonicalNamespace,
3837            'wgCanonicalSpecialPageName' => $canonicalSpecialPageName,
3838            'wgNamespaceNumber' => $title->getNamespace(),
3839            'wgPageName' => $title->getPrefixedDBkey(),
3840            'wgTitle' => $title->getText(),
3841            'wgCurRevisionId' => $curRevisionId,
3842            'wgRevisionId' => (int)$this->getRevisionId(),
3843            'wgArticleId' => $articleId,
3844            'wgIsArticle' => $this->isArticle(),
3845            'wgIsRedirect' => $title->isRedirect(),
3846            'wgAction' => $this->getContext()->getActionName(),
3847            'wgUserName' => $user->isAnon() ? null : $user->getName(),
3848            'wgUserGroups' => $services->getUserGroupManager()->getUserEffectiveGroups( $user ),
3849            'wgCategories' => $this->getCategories(),
3850            'wgPageViewLanguage' => $lang->getCode(),
3851            'wgPageContentLanguage' => $lang->getCode(),
3852            'wgPageContentModel' => $title->getContentModel(),
3853            'wgRelevantPageName' => $relevantTitle->getPrefixedDBkey(),
3854            'wgRelevantArticleId' => $relevantTitle->getArticleID(),
3855        ];
3856        if ( $user->isRegistered() ) {
3857            $vars['wgUserId'] = $user->getId();
3858            $vars['wgUserIsTemp'] = $user->isTemp();
3859            $vars['wgUserEditCount'] = $user->getEditCount();
3860            $userReg = $user->getRegistration();
3861            $vars['wgUserRegistration'] = $userReg ? (int)wfTimestamp( TS_UNIX, $userReg ) * 1000 : null;
3862            $userFirstReg = $services->getUserRegistrationLookup()->getFirstRegistration( $user );
3863            $vars['wgUserFirstRegistration'] = $userFirstReg ? (int)wfTimestamp( TS_UNIX, $userFirstReg ) * 1000 : null;
3864            // Get the revision ID of the oldest new message on the user's talk
3865            // page. This can be used for constructing new message alerts on
3866            // the client side.
3867            $userNewMsgRevId = $this->getLastSeenUserTalkRevId();
3868            // Only occupy precious space in the <head> when it is non-null (T53640)
3869            // mw.config.get returns null by default.
3870            if ( $userNewMsgRevId ) {
3871                $vars['wgUserNewMsgRevisionId'] = $userNewMsgRevId;
3872            }
3873        } else {
3874            $tempUserCreator = $services->getTempUserCreator();
3875            if ( $tempUserCreator->isEnabled() ) {
3876                // For logged-out users only (without a temporary account): get the user name that will
3877                // be used for their temporary account, if it has already been acquired.
3878                // This may be used in previews.
3879                $session = $this->getRequest()->getSession();
3880                $vars['wgTempUserName'] = $tempUserCreator->getStashedName( $session );
3881            }
3882        }
3883        $languageConverter = $services->getLanguageConverterFactory()
3884            ->getLanguageConverter( $title->getPageLanguage() );
3885        if ( $languageConverter->hasVariants() ) {
3886            $vars['wgUserVariant'] = $languageConverter->getPreferredVariant();
3887        }
3888        // Same test as SkinTemplate
3889        $vars['wgIsProbablyEditable'] = $this->getAuthority()->probablyCan( 'edit', $title );
3890        $vars['wgRelevantPageIsProbablyEditable'] = $relevantTitle &&
3891            $this->getAuthority()->probablyCan( 'edit', $relevantTitle );
3892        $restrictionStore = $services->getRestrictionStore();
3893        foreach ( $restrictionStore->listApplicableRestrictionTypes( $title ) as $type ) {
3894            // Following keys are set in $vars:
3895            // wgRestrictionCreate, wgRestrictionEdit, wgRestrictionMove, wgRestrictionUpload
3896            $vars['wgRestriction' . ucfirst( $type )] = $restrictionStore->getRestrictions( $title, $type );
3897        }
3898        if ( $title->isMainPage() ) {
3899            $vars['wgIsMainPage'] = true;
3900        }
3901
3902        $relevantUser = $sk->getRelevantUser();
3903        if ( $relevantUser ) {
3904            $vars['wgRelevantUserName'] = $relevantUser->getName();
3905        }
3906        // End of stable config vars
3907
3908        $titleFormatter = $services->getTitleFormatter();
3909
3910        if ( $this->mRedirectedFrom ) {
3911            // @internal For skin JS
3912            $vars['wgRedirectedFrom'] = $titleFormatter->getPrefixedDBkey( $this->mRedirectedFrom );
3913        }
3914
3915        // Allow extensions to add their custom variables to the mw.config map.
3916        // Use the 'ResourceLoaderGetConfigVars' hook if the variable is not
3917        // page-dependent but site-wide (without state).
3918        // Alternatively, you may want to use OutputPage->addJsConfigVars() instead.
3919        $this->getHookRunner()->onMakeGlobalVariablesScript( $vars, $this );
3920
3921        // Merge in variables from addJsConfigVars last
3922        $vars = array_merge( $vars, $this->getJsConfigVars() );
3923
3924        // Return only early or late vars if requested
3925        if ( $flag !== null ) {
3926            $lateVarNames =
3927                array_fill_keys( self::CORE_LATE_JS_CONFIG_VAR_NAMES, true ) +
3928                array_fill_keys( ExtensionRegistry::getInstance()->getAttribute( 'LateJSConfigVarNames' ), true );
3929            foreach ( $vars as $name => $_ ) {
3930                // If the variable's late flag doesn't match the requested late flag, unset it
3931                if ( isset( $lateVarNames[ $name ] ) !== ( $flag === self::JS_VAR_LATE ) ) {
3932                    unset( $vars[ $name ] );
3933                }
3934            }
3935        }
3936
3937        return $vars;
3938    }
3939
3940    /**
3941     * Get the revision ID for the last user talk page revision viewed by the talk page owner.
3942     *
3943     * @return int|null
3944     */
3945    private function getLastSeenUserTalkRevId() {
3946        $services = MediaWikiServices::getInstance();
3947        $user = $this->getUser();
3948        $userHasNewMessages = $services
3949            ->getTalkPageNotificationManager()
3950            ->userHasNewMessages( $user );
3951        if ( !$userHasNewMessages ) {
3952            return null;
3953        }
3954
3955        $timestamp = $services
3956            ->getTalkPageNotificationManager()
3957            ->getLatestSeenMessageTimestamp( $user );
3958        if ( !$timestamp ) {
3959            return null;
3960        }
3961
3962        $revRecord = $services->getRevisionLookup()->getRevisionByTimestamp(
3963            $user->getTalkPage(),
3964            $timestamp
3965        );
3966        return $revRecord ? $revRecord->getId() : null;
3967    }
3968
3969    /**
3970     * To make it harder for someone to slip a user a fake
3971     * JavaScript or CSS preview, a random token
3972     * is associated with the login session. If it's not
3973     * passed back with the preview request, we won't render
3974     * the code.
3975     *
3976     * @return bool
3977     */
3978    public function userCanPreview() {
3979        $request = $this->getRequest();
3980        if (
3981            $request->getRawVal( 'action' ) !== 'submit' ||
3982            !$request->wasPosted()
3983        ) {
3984            return false;
3985        }
3986
3987        $user = $this->getUser();
3988
3989        if ( !$user->isRegistered() ) {
3990            // Anons have predictable edit tokens
3991            return false;
3992        }
3993        if ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
3994            return false;
3995        }
3996
3997        $title = $this->getTitle();
3998        if ( !$this->getAuthority()->probablyCan( 'edit', $title ) ) {
3999            return false;
4000        }
4001
4002        return true;
4003    }
4004
4005    /**
4006     * @return array Array in format "link name or number => 'link html'".
4007     */
4008    public function getHeadLinksArray() {
4009        $tags = [];
4010        $config = $this->getConfig();
4011
4012        if ( $this->cspOutputMode === self::CSP_META ) {
4013            foreach ( $this->CSP->getDirectives() as $header => $directive ) {
4014                $tags["meta-csp-$header"] = Html::element( 'meta', [
4015                    'http-equiv' => $header,
4016                    'content' => $directive,
4017                ] );
4018            }
4019        }
4020
4021        $tags['meta-generator'] = Html::element( 'meta', [
4022            'name' => 'generator',
4023            'content' => 'MediaWiki ' . MW_VERSION,
4024        ] );
4025
4026        if ( $config->get( MainConfigNames::ReferrerPolicy ) !== false ) {
4027            // Per https://w3c.github.io/webappsec-referrer-policy/#unknown-policy-values
4028            // fallbacks should come before the primary value so we need to reverse the array.
4029            foreach ( array_reverse( (array)$config->get( MainConfigNames::ReferrerPolicy ) ) as $i => $policy ) {
4030                $tags["meta-referrer-$i"] = Html::element( 'meta', [
4031                    'name' => 'referrer',
4032                    'content' => $policy,
4033                ] );
4034            }
4035        }
4036
4037        $p = $this->getRobotsContent();
4038        if ( $p ) {
4039            // http://www.robotstxt.org/wc/meta-user.html
4040            // Only show if it's different from the default robots policy
4041            $tags['meta-robots'] = Html::element( 'meta', [
4042                'name' => 'robots',
4043                'content' => $p,
4044            ] );
4045        }
4046
4047        # Browser based phonenumber detection
4048        if ( $config->get( MainConfigNames::BrowserFormatDetection ) !== false ) {
4049            $tags['meta-format-detection'] = Html::element( 'meta', [
4050                'name' => 'format-detection',
4051                'content' => $config->get( MainConfigNames::BrowserFormatDetection ),
4052            ] );
4053        }
4054
4055        foreach ( $this->mMetatags as [ $name, $val ] ) {
4056            $attrs = [];
4057            if ( strncasecmp( $name, 'http:', 5 ) === 0 ) {
4058                $name = substr( $name, 5 );
4059                $attrs['http-equiv'] = $name;
4060            } elseif ( strncasecmp( $name, 'og:', 3 ) === 0 ) {
4061                $attrs['property'] = $name;
4062            } else {
4063                $attrs['name'] = $name;
4064            }
4065            $attrs['content'] = $val;
4066            $tagName = "meta-$name";
4067            if ( isset( $tags[$tagName] ) ) {
4068                $tagName .= $val;
4069            }
4070            $tags[$tagName] = Html::element( 'meta', $attrs );
4071        }
4072
4073        foreach ( $this->mLinktags as $tag ) {
4074            $tags[] = Html::element( 'link', $tag );
4075        }
4076
4077        if ( $config->get( MainConfigNames::UniversalEditButton ) && $this->isArticleRelated() ) {
4078            if ( $this->getAuthority()->probablyCan( 'edit', $this->getTitle() ) ) {
4079                $msg = $this->msg( 'edit' )->text();
4080                // Use mime type per https://phabricator.wikimedia.org/T21165#6946526
4081                $tags['universal-edit-button'] = Html::element( 'link', [
4082                    'rel' => 'alternate',
4083                    'type' => 'application/x-wiki',
4084                    'title' => $msg,
4085                    'href' => $this->getTitle()->getEditURL(),
4086                ] );
4087            }
4088        }
4089
4090        # Generally the order of the favicon and apple-touch-icon links
4091        # should not matter, but Konqueror (3.5.9 at least) incorrectly
4092        # uses whichever one appears later in the HTML source. Make sure
4093        # apple-touch-icon is specified first to avoid this.
4094        $appleTouchIconHref = $config->get( MainConfigNames::AppleTouchIcon );
4095        # Browser look for those by default, unnessecary to set a link tag
4096        if (
4097            $appleTouchIconHref !== false &&
4098            $appleTouchIconHref !== '/apple-touch-icon.png' &&
4099            $appleTouchIconHref !== '/apple-touch-icon-precomposed.png'
4100        ) {
4101            $tags['apple-touch-icon'] = Html::element( 'link', [
4102                'rel' => 'apple-touch-icon',
4103                'href' => $appleTouchIconHref
4104            ] );
4105        }
4106
4107        $faviconHref = $config->get( MainConfigNames::Favicon );
4108        # Browser look for those by default, unnessecary to set a link tag
4109        if ( $faviconHref !== false && $faviconHref !== '/favicon.ico' ) {
4110            $tags['favicon'] = Html::element( 'link', [
4111                'rel' => 'icon',
4112                'href' => $faviconHref
4113            ] );
4114        }
4115
4116        # OpenSearch description link
4117        $tags['opensearch'] = Html::element( 'link', [
4118            'rel' => 'search',
4119            'type' => 'application/opensearchdescription+xml',
4120            'href' => wfScript( 'opensearch_desc' ),
4121            'title' => $this->msg( 'opensearch-desc' )->inContentLanguage()->text(),
4122        ] );
4123
4124        $services = MediaWikiServices::getInstance();
4125
4126        # Real Simple Discovery link, provides auto-discovery information
4127        # for the MediaWiki API (and potentially additional custom API
4128        # support such as WordPress or Twitter-compatible APIs for a
4129        # blogging extension, etc)
4130        $tags['rsd'] = Html::element( 'link', [
4131            'rel' => 'EditURI',
4132            'type' => 'application/rsd+xml',
4133            // Output a protocol-relative URL here if $wgServer is protocol-relative.
4134            // Whether RSD accepts relative or protocol-relative URLs is completely
4135            // undocumented, though.
4136            'href' => (string)$services->getUrlUtils()->expand( wfAppendQuery(
4137                wfScript( 'api' ),
4138                [ 'action' => 'rsd' ] ),
4139                PROTO_RELATIVE
4140            ),
4141        ] );
4142
4143        $tags = array_merge(
4144            $tags,
4145            $this->getHeadLinksCanonicalURLArray( $config ),
4146            $this->getHeadLinksAlternateURLsArray(),
4147            $this->getHeadLinksCopyrightArray( $config ),
4148            $this->getHeadLinksSyndicationArray( $config ),
4149        );
4150
4151        // Allow extensions to add, remove and/or otherwise manipulate these links
4152        // If you want only to *add* <head> links, please use the addHeadItem()
4153        // (or addHeadItems() for multiple items) method instead.
4154        // This hook is provided as a last resort for extensions to modify these
4155        // links before the output is sent to client.
4156        $this->getHookRunner()->onOutputPageAfterGetHeadLinksArray( $tags, $this );
4157
4158        return $tags;
4159    }
4160
4161    /**
4162     * Canonical URL and alternate URLs
4163     *
4164     * isCanonicalUrlAction affects all requests where "setArticleRelated" is true.
4165     * This is typically all requests that show content (query title, curid, oldid, diff),
4166     *  and all wikipage actions (edit, delete, purge, info, history etc.).
4167     * It does not apply to file pages and special pages.
4168     * 'history' and 'info' actions address page metadata rather than the page
4169     *  content itself, so they may not be canonicalized to the view page url.
4170     * TODO: this logic should be owned by Action subclasses.
4171     * See T67402
4172     */
4173
4174    /**
4175     * Get head links relating to the canonical URL
4176     * Note: There should only be one canonical URL.
4177     * @param Config $config
4178     * @return array
4179     */
4180    private function getHeadLinksCanonicalURLArray( Config $config ) {
4181        $tags = [];
4182        $canonicalUrl = $this->mCanonicalUrl;
4183
4184        if ( $config->get( MainConfigNames::EnableCanonicalServerLink ) ) {
4185            $query = [];
4186            $action = $this->getContext()->getActionName();
4187            $isCanonicalUrlAction = in_array( $action, [ 'history', 'info' ] );
4188            $services = MediaWikiServices::getInstance();
4189            $languageConverterFactory = $services->getLanguageConverterFactory();
4190            $isLangConversionDisabled = $languageConverterFactory->isConversionDisabled();
4191            $pageLang = $this->getTitle()->getPageLanguage();
4192            $pageLanguageConverter = $languageConverterFactory->getLanguageConverter( $pageLang );
4193            $urlVariant = $pageLanguageConverter->getURLVariant();
4194
4195            if ( $canonicalUrl !== false ) {
4196                $canonicalUrl = (string)$services->getUrlUtils()->expand( $canonicalUrl, PROTO_CANONICAL );
4197            } elseif ( $this->isArticleRelated() ) {
4198                if ( $isCanonicalUrlAction ) {
4199                    $query['action'] = $action;
4200                } elseif ( !$isLangConversionDisabled && $urlVariant ) {
4201                    # T54429, T108443: Making canonical URL language-variant-aware.
4202                    $query['variant'] = $urlVariant;
4203                }
4204                $canonicalUrl = $this->getTitle()->getCanonicalURL( $query );
4205            } else {
4206                $reqUrl = $this->getRequest()->getRequestURL();
4207                $canonicalUrl = (string)$services->getUrlUtils()->expand( $reqUrl, PROTO_CANONICAL );
4208            }
4209        }
4210
4211        if ( $canonicalUrl !== false ) {
4212            $tags['link-canonical'] = Html::element( 'link', [
4213                'rel' => 'canonical',
4214                'href' => $canonicalUrl
4215            ] );
4216        }
4217
4218        return $tags;
4219    }
4220
4221    /**
4222     * Get head links relating to alternate URL(s) in languages including language variants
4223     * Output fully-qualified URL since meta alternate URLs must be fully-qualified
4224     * Per https://developers.google.com/search/docs/advanced/crawling/localized-versions
4225     * See T294716
4226     *
4227     * @return array
4228     */
4229    private function getHeadLinksAlternateURLsArray() {
4230        $tags = [];
4231        $languageUrls = [];
4232        $action = $this->getContext()->getActionName();
4233        $isCanonicalUrlAction = in_array( $action, [ 'history', 'info' ] );
4234        $services = MediaWikiServices::getInstance();
4235        $languageConverterFactory = $services->getLanguageConverterFactory();
4236        $isLangConversionDisabled = $languageConverterFactory->isConversionDisabled();
4237        $pageLang = $this->getTitle()->getPageLanguage();
4238        $pageLanguageConverter = $languageConverterFactory->getLanguageConverter( $pageLang );
4239
4240        # Language variants
4241        if (
4242            $this->isArticleRelated() &&
4243            !$isCanonicalUrlAction &&
4244            $pageLanguageConverter->hasVariants() &&
4245            !$isLangConversionDisabled
4246        ) {
4247            $variants = $pageLanguageConverter->getVariants();
4248            foreach ( $variants as $variant ) {
4249                $bcp47 = LanguageCode::bcp47( $variant );
4250                $languageUrls[$bcp47] = $this->getTitle()
4251                    ->getFullURL( [ 'variant' => $variant ], false, PROTO_CURRENT );
4252            }
4253        }
4254
4255        # Alternate URLs for interlanguage links would be handeled in HTML body tag instead of
4256        #  head tag, see T326829.
4257
4258        if ( $languageUrls ) {
4259            # Force the alternate URL of page language code to be self.
4260            # T123901, T305540, T108443: Override mixed-variant variant link in language variant links.
4261            $currentUrl = $this->getTitle()->getFullURL( [], false, PROTO_CURRENT );
4262            $pageLangCodeBcp47 = LanguageCode::bcp47( $pageLang->getCode() );
4263            $languageUrls[$pageLangCodeBcp47] = $currentUrl;
4264
4265            ksort( $languageUrls );
4266
4267            # Also add x-default link per https://support.google.com/webmasters/answer/189077?hl=en
4268            $languageUrls['x-default'] = $currentUrl;
4269
4270            # Process all of language variants and interlanguage links
4271            foreach ( $languageUrls as $bcp47 => $languageUrl ) {
4272                $bcp47lowercase = strtolower( $bcp47 );
4273                $tags['link-alternate-language-' . $bcp47lowercase] = Html::element( 'link', [
4274                    'rel' => 'alternate',
4275                    'hreflang' => $bcp47,
4276                    'href' => $languageUrl,
4277                ] );
4278            }
4279        }
4280
4281        return $tags;
4282    }
4283
4284    /**
4285     * Get head links relating to copyright
4286     *
4287     * @param Config $config
4288     * @return array
4289     */
4290    private function getHeadLinksCopyrightArray( Config $config ) {
4291        $tags = [];
4292
4293        if ( $this->copyrightUrl !== null ) {
4294            $copyright = $this->copyrightUrl;
4295        } else {
4296            $copyright = '';
4297            if ( $config->get( MainConfigNames::RightsPage ) ) {
4298                $copy = Title::newFromText( $config->get( MainConfigNames::RightsPage ) );
4299
4300                if ( $copy ) {
4301                    $copyright = $copy->getLocalURL();
4302                }
4303            }
4304
4305            if ( !$copyright && $config->get( MainConfigNames::RightsUrl ) ) {
4306                $copyright = $config->get( MainConfigNames::RightsUrl );
4307            }
4308        }
4309
4310        if ( $copyright ) {
4311            $tags['copyright'] = Html::element( 'link', [
4312                'rel' => 'license',
4313                'href' => $copyright
4314            ] );
4315        }
4316
4317        return $tags;
4318    }
4319
4320    /**
4321     * Get head links relating to syndication feeds.
4322     *
4323     * @param Config $config
4324     * @return array
4325     */
4326    private function getHeadLinksSyndicationArray( Config $config ) {
4327        if ( !$config->get( MainConfigNames::Feed ) ) {
4328            return [];
4329        }
4330
4331        $tags = [];
4332        $feedLinks = [];
4333
4334        foreach ( $this->getSyndicationLinks() as $format => $link ) {
4335            # Use the page name for the title.  In principle, this could
4336            # lead to issues with having the same name for different feeds
4337            # corresponding to the same page, but we can't avoid that at
4338            # this low a level.
4339
4340            $feedLinks[] = $this->feedLink(
4341                $format,
4342                $link,
4343                # Used messages: 'page-rss-feed' and 'page-atom-feed' (for an easier grep)
4344                $this->msg(
4345                    "page-{$format}-feed", $this->getTitle()->getPrefixedText()
4346                )->text()
4347            );
4348        }
4349
4350        # Recent changes feed should appear on every page (except recentchanges,
4351        # that would be redundant). Put it after the per-page feed to avoid
4352        # changing existing behavior. It's still available, probably via a
4353        # menu in your browser. Some sites might have a different feed they'd
4354        # like to promote instead of the RC feed (maybe like a "Recent New Articles"
4355        # or "Breaking news" one). For this, we see if $wgOverrideSiteFeed is defined.
4356        # If so, use it instead.
4357        $sitename = $config->get( MainConfigNames::Sitename );
4358        $overrideSiteFeed = $config->get( MainConfigNames::OverrideSiteFeed );
4359        if ( $overrideSiteFeed ) {
4360            foreach ( $overrideSiteFeed as $type => $feedUrl ) {
4361                // Note, this->feedLink escapes the url.
4362                $feedLinks[] = $this->feedLink(
4363                    $type,
4364                    $feedUrl,
4365                    $this->msg( "site-{$type}-feed", $sitename )->text()
4366                );
4367            }
4368        } elseif ( !$this->getTitle()->isSpecial( 'Recentchanges' ) ) {
4369            $rctitle = SpecialPage::getTitleFor( 'Recentchanges' );
4370            foreach ( $this->getAdvertisedFeedTypes() as $format ) {
4371                $feedLinks[] = $this->feedLink(
4372                    $format,
4373                    $rctitle->getLocalURL( [ 'feed' => $format ] ),
4374                    # For grep: 'site-rss-feed', 'site-atom-feed'
4375                    $this->msg( "site-{$format}-feed", $sitename )->text()
4376                );
4377            }
4378        }
4379
4380        # Allow extensions to change the list pf feeds. This hook is primarily for changing,
4381        # manipulating or removing existing feed tags. If you want to add new feeds, you should
4382        # use OutputPage::addFeedLink() instead.
4383        $this->getHookRunner()->onAfterBuildFeedLinks( $feedLinks );
4384
4385        $tags += $feedLinks;
4386
4387        return $tags;
4388    }
4389
4390    /**
4391     * Generate a "<link rel/>" for a feed.
4392     *
4393     * @param string $type Feed type
4394     * @param string $url URL to the feed
4395     * @param string $text Value of the "title" attribute
4396     * @return string HTML fragment
4397     */
4398    private function feedLink( $type, $url, $text ) {
4399        return Html::element( 'link', [
4400            'rel' => 'alternate',
4401            'type' => "application/$type+xml",
4402            'title' => $text,
4403            'href' => $url ]
4404        );
4405    }
4406
4407    /**
4408     * Add a local or specified stylesheet, with the given media options.
4409     * Internal use only. Use OutputPage::addModuleStyles() if possible.
4410     *
4411     * @param string $style URL to the file
4412     * @param string $media To specify a media type, 'screen', 'printable', 'handheld' or any.
4413     * @param string $condition For IE conditional comments, specifying an IE version
4414     * @param string $dir Set to 'rtl' or 'ltr' for direction-specific sheets
4415     */
4416    public function addStyle( $style, $media = '', $condition = '', $dir = '' ) {
4417        $options = [];
4418        if ( $media ) {
4419            $options['media'] = $media;
4420        }
4421        if ( $condition ) {
4422            $options['condition'] = $condition;
4423        }
4424        if ( $dir ) {
4425            $options['dir'] = $dir;
4426        }
4427        $this->styles[$style] = $options;
4428    }
4429
4430    /**
4431     * Adds inline CSS styles
4432     * Internal use only. Use OutputPage::addModuleStyles() if possible.
4433     *
4434     * @param mixed $style_css Inline CSS
4435     * @param-taint $style_css exec_html
4436     * @param string $flip Set to 'flip' to flip the CSS if needed
4437     */
4438    public function addInlineStyle( $style_css, $flip = 'noflip' ) {
4439        if ( $flip === 'flip' && $this->getLanguage()->isRTL() ) {
4440            # If wanted, and the interface is right-to-left, flip the CSS
4441            $style_css = CSSJanus::transform( $style_css, true, false );
4442        }
4443        $this->mInlineStyles .= Html::inlineStyle( $style_css );
4444    }
4445
4446    /**
4447     * Build exempt modules and legacy non-ResourceLoader styles.
4448     *
4449     * @return string|WrappedStringList HTML
4450     */
4451    protected function buildExemptModules() {
4452        $chunks = [];
4453
4454        // Requirements:
4455        // - Within modules provided by the software (core, skin, extensions),
4456        //   styles from skin stylesheets should be overridden by styles
4457        //   from modules dynamically loaded with JavaScript.
4458        // - Styles from site-specific, private, and user modules should override
4459        //   both of the above.
4460        //
4461        // The effective order for stylesheets must thus be:
4462        // 1. Page style modules, formatted server-side by RL\ClientHtml.
4463        // 2. Dynamically-loaded styles, inserted client-side by mw.loader.
4464        // 3. Styles that are site-specific, private or from the user, formatted
4465        //    server-side by this function.
4466        //
4467        // The 'ResourceLoaderDynamicStyles' marker helps JavaScript know where
4468        // point #2 is.
4469
4470        // Add legacy styles added through addStyle()/addInlineStyle() here
4471        $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
4472
4473        // Things that go after the ResourceLoaderDynamicStyles marker
4474        $append = [];
4475        $separateReq = [ 'site.styles', 'user.styles' ];
4476        foreach ( $this->rlExemptStyleModules as $moduleNames ) {
4477            if ( $moduleNames ) {
4478                $append[] = $this->makeResourceLoaderLink(
4479                    array_diff( $moduleNames, $separateReq ),
4480                    RL\Module::TYPE_STYLES
4481                );
4482
4483                foreach ( array_intersect( $moduleNames, $separateReq ) as $name ) {
4484                    // These require their own dedicated request in order to support "@import"
4485                    // syntax, which is incompatible with concatenation. (T147667, T37562)
4486                    $append[] = $this->makeResourceLoaderLink( $name,
4487                        RL\Module::TYPE_STYLES
4488                    );
4489                }
4490            }
4491        }
4492        if ( $append ) {
4493            $chunks[] = Html::element(
4494                'meta',
4495                [ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ]
4496            );
4497            $chunks = array_merge( $chunks, $append );
4498        }
4499
4500        return self::combineWrappedStrings( $chunks );
4501    }
4502
4503    /**
4504     * @return array
4505     */
4506    public function buildCssLinksArray() {
4507        $links = [];
4508
4509        foreach ( $this->styles as $file => $options ) {
4510            $link = $this->styleLink( $file, $options );
4511            if ( $link ) {
4512                $links[$file] = $link;
4513            }
4514        }
4515        return $links;
4516    }
4517
4518    /**
4519     * Generate \<link\> tags for stylesheets
4520     *
4521     * @param string $style URL to the file
4522     * @param array $options Option, can contain 'condition', 'dir', 'media' keys
4523     * @return string HTML fragment
4524     */
4525    protected function styleLink( $style, array $options ) {
4526        if ( isset( $options['dir'] ) && $this->getLanguage()->getDir() != $options['dir'] ) {
4527            return '';
4528        }
4529
4530        if ( isset( $options['media'] ) ) {
4531            $media = self::transformCssMedia( $options['media'] );
4532            if ( $media === null ) {
4533                return '';
4534            }
4535        } else {
4536            $media = 'all';
4537        }
4538
4539        if ( str_starts_with( $style, '/' ) ||
4540            str_starts_with( $style, 'http:' ) ||
4541            str_starts_with( $style, 'https:' )
4542        ) {
4543            $url = $style;
4544        } else {
4545            $config = $this->getConfig();
4546            // Append file hash as query parameter
4547            $url = self::transformResourcePath(
4548                $config,
4549                $config->get( MainConfigNames::StylePath ) . '/' . $style
4550            );
4551        }
4552
4553        $link = Html::linkedStyle( $url, $media );
4554
4555        if ( isset( $options['condition'] ) ) {
4556            $condition = htmlspecialchars( $options['condition'] );
4557            $link = "<!--[if $condition]>$link<![endif]-->";
4558        }
4559        return $link;
4560    }
4561
4562    /**
4563     * Transform path to web-accessible static resource.
4564     *
4565     * This is used to add a validation hash as query string.
4566     * This aids various behaviors:
4567     *
4568     * - Put long Cache-Control max-age headers on responses for improved
4569     *   cache performance.
4570     * - Get the correct version of a file as expected by the current page.
4571     * - Instantly get the updated version of a file after deployment.
4572     *
4573     * Avoid using this for urls included in HTML as otherwise clients may get different
4574     * versions of a resource when navigating the site depending on when the page was cached.
4575     * If changes to the url propagate, this is not a problem (e.g. if the url is in
4576     * an external stylesheet).
4577     *
4578     * @since 1.27
4579     * @param Config $config
4580     * @param string $path Path-absolute URL to file (from document root, must start with "/")
4581     * @return string URL
4582     */
4583    public static function transformResourcePath( Config $config, $path ) {
4584        $localDir = $config->get( MainConfigNames::BaseDirectory );
4585        $remotePathPrefix = $config->get( MainConfigNames::ResourceBasePath );
4586        if ( $remotePathPrefix === '' ) {
4587            // The configured base path is required to be empty string for
4588            // wikis in the domain root
4589            $remotePath = '/';
4590        } else {
4591            $remotePath = $remotePathPrefix;
4592        }
4593        if ( !str_starts_with( $path, $remotePath ) || str_starts_with( $path, '//' ) ) {
4594            // - Path is outside wgResourceBasePath, ignore.
4595            // - Path is protocol-relative. Fixes T155310. Not supported by RelPath lib.
4596            return $path;
4597        }
4598        // For files in resources, extensions/ or skins/, ResourceBasePath is preferred here.
4599        // For other misc files in $IP, we'll fallback to that as well. There is, however, a fourth
4600        // supported dir/path pair in the configuration (wgUploadDirectory, wgUploadPath)
4601        // which is not expected to be in wgResourceBasePath on CDNs. (T155146)
4602        $uploadPath = $config->get( MainConfigNames::UploadPath );
4603        if ( strpos( $path, $uploadPath ) === 0 ) {
4604            $localDir = $config->get( MainConfigNames::UploadDirectory );
4605            $remotePathPrefix = $remotePath = $uploadPath;
4606        }
4607
4608        $path = RelPath::getRelativePath( $path, $remotePath );
4609        return self::transformFilePath( $remotePathPrefix, $localDir, $path );
4610    }
4611
4612    /**
4613     * Utility method for transformResourceFilePath().
4614     *
4615     * Caller is responsible for ensuring the file exists. Emits a PHP warning otherwise.
4616     *
4617     * @since 1.27
4618     * @param string $remotePathPrefix URL path prefix that points to $localPath
4619     * @param string $localPath File directory exposed at $remotePath
4620     * @param string $file Path to target file relative to $localPath
4621     * @return string URL
4622     */
4623    public static function transformFilePath( $remotePathPrefix, $localPath, $file ) {
4624        // This MUST match the equivalent logic in CSSMin::remapOne()
4625        $localFile = "$localPath/$file";
4626        $url = "$remotePathPrefix/$file";
4627        if ( is_file( $localFile ) ) {
4628            $hash = md5_file( $localFile );
4629            if ( $hash === false ) {
4630                wfLogWarning( __METHOD__ . ": Failed to hash $localFile" );
4631                $hash = '';
4632            }
4633            $url .= '?' . substr( $hash, 0, 5 );
4634        }
4635        return $url;
4636    }
4637
4638    /**
4639     * Transform "media" attribute based on request parameters
4640     *
4641     * @param string $media Current value of the "media" attribute
4642     * @return string|null Modified value of the "media" attribute, or null to disable
4643     * this stylesheet
4644     */
4645    public static function transformCssMedia( $media ) {
4646        global $wgRequest;
4647
4648        if ( $wgRequest->getBool( 'printable' ) ) {
4649            // When browsing with printable=yes, apply "print" media styles
4650            // as if they are screen styles (no media, media="").
4651            if ( $media === 'print' ) {
4652                return '';
4653            }
4654
4655            // https://www.w3.org/TR/css3-mediaqueries/#syntax
4656            //
4657            // This regex will not attempt to understand a comma-separated media_query_list
4658            // Example supported values for $media:
4659            //
4660            //     'screen', 'only screen', 'screen and (min-width: 982px)' ),
4661            //
4662            // Example NOT supported value for $media:
4663            //
4664            //     '3d-glasses, screen, print and resolution > 90dpi'
4665            //
4666            // If it's a "printable" request, we disable all screen stylesheets.
4667            $screenMediaQueryRegex = '/^(?:only\s+)?screen\b/i';
4668            if ( preg_match( $screenMediaQueryRegex, $media ) === 1 ) {
4669                return null;
4670            }
4671        }
4672
4673        return $media;
4674    }
4675
4676    /**
4677     * Add a wikitext-formatted message to the output.
4678     *
4679     * @param string|MessageSpecifier $name Message key
4680     * @param mixed ...$args Message parameters. Unlike wfMessage(), this method only accepts
4681     *     variadic parameters (they can't be passed as a single array parameter).
4682     */
4683    public function addWikiMsg( $name, ...$args ) {
4684        $this->addWikiMsgArray( $name, $args );
4685    }
4686
4687    /**
4688     * Add a wikitext-formatted message to the output.
4689     *
4690     * @param string|MessageSpecifier $name Message key
4691     * @param array $args Message parameters. Unlike wfMessage(), this method only accepts
4692     *     the parameters as an array (they can't be passed as variadic parameters),
4693     *     or just a single parameter (this only works by accident, don't rely on it).
4694     */
4695    public function addWikiMsgArray( $name, $args ) {
4696        $this->addHTML( $this->msg( $name, $args )->parseAsBlock() );
4697    }
4698
4699    /**
4700     * This function takes a number of message/argument specifications, wraps them in
4701     * some overall structure, and then parses the result and adds it to the output.
4702     *
4703     * In the $wrap, $1 is replaced with the first message, $2 with the second,
4704     * and so on. The subsequent arguments may be either
4705     * 1) strings, in which case they are message names, or
4706     * 2) arrays, in which case, within each array, the first element is the message
4707     *    name, and subsequent elements are the parameters to that message.
4708     *
4709     * Don't use this for messages that are not in the user's interface language.
4710     *
4711     * For example:
4712     *
4713     *     $wgOut->wrapWikiMsg( "<div class='customclass'>\n$1\n</div>", 'some-msg-key' );
4714     *
4715     * Is equivalent to:
4716     *
4717     *     $wgOut->addWikiTextAsInterface( "<div class='customclass'>\n"
4718     *         . wfMessage( 'some-msg-key' )->plain() . "\n</div>" );
4719     *
4720     * The newline after the opening div is needed in some wikitext. See T21226.
4721     *
4722     * @param string $wrap
4723     * @param mixed ...$msgSpecs
4724     */
4725    public function wrapWikiMsg( $wrap, ...$msgSpecs ) {
4726        $s = $wrap;
4727        foreach ( $msgSpecs as $n => $spec ) {
4728            if ( is_array( $spec ) ) {
4729                $args = $spec;
4730                $name = array_shift( $args );
4731            } else {
4732                $args = [];
4733                $name = $spec;
4734            }
4735            $s = str_replace( '$' . ( $n + 1 ), $this->msg( $name, $args )->plain(), $s );
4736        }
4737        $this->addWikiTextAsInterface( $s );
4738    }
4739
4740    /**
4741     * Whether the output has a table of contents when the ToC is
4742     * rendered inline.
4743     * @return bool
4744     * @since 1.22
4745     */
4746    public function isTOCEnabled() {
4747        return $this->mEnableTOC;
4748    }
4749
4750    /**
4751     * Helper function to setup the PHP implementation of OOUI to use in this request.
4752     *
4753     * @since 1.26
4754     * @param string|null $skinName Ignored since 1.41
4755     * @param string|null $dir Ignored since 1.41
4756     */
4757    public static function setupOOUI( $skinName = null, $dir = null ) {
4758        if ( !self::$oouiSetupDone ) {
4759            self::$oouiSetupDone = true;
4760            $context = RequestContext::getMain();
4761            $skinName = $context->getSkinName();
4762            $dir = $context->getLanguage()->getDir();
4763            $themes = RL\OOUIFileModule::getSkinThemeMap();
4764            $theme = $themes[$skinName] ?? $themes['default'];
4765            // For example, 'OOUI\WikimediaUITheme'.
4766            $themeClass = "OOUI\\{$theme}Theme";
4767            Theme::setSingleton( new $themeClass() );
4768            Element::setDefaultDir( $dir );
4769        }
4770    }
4771
4772    /**
4773     * Notify of a change in global skin or language which would necessitate
4774     * reinitialization of OOUI global static data.
4775     * @internal
4776     */
4777    public static function resetOOUI() {
4778        if ( self::$oouiSetupDone ) {
4779            self::$oouiSetupDone = false;
4780            self::setupOOUI();
4781        }
4782    }
4783
4784    /**
4785     * Add ResourceLoader module styles for OOUI and set up the PHP implementation of it for use with
4786     * MediaWiki and this OutputPage instance.
4787     *
4788     * @since 1.25
4789     */
4790    public function enableOOUI() {
4791        self::setupOOUI();
4792        $this->addModuleStyles( [
4793            'oojs-ui-core.styles',
4794            'oojs-ui.styles.indicators',
4795            'mediawiki.widgets.styles',
4796            'oojs-ui-core.icons',
4797        ] );
4798    }
4799
4800    /**
4801     * Get (and set if not yet set) the CSP nonce.
4802     *
4803     * This value needs to be included in any <script> tags on the
4804     * page.
4805     *
4806     * @return string|false Nonce or false to mean don't output nonce
4807     * @since 1.32
4808     * @deprecated Since 1.35 use getCSP()->getNonce() instead
4809     */
4810    public function getCSPNonce() {
4811        wfDeprecated( __METHOD__, '1.35' );
4812        return $this->CSP->getNonce();
4813    }
4814
4815    /**
4816     * Get the ContentSecurityPolicy object
4817     *
4818     * @since 1.35
4819     * @return ContentSecurityPolicy
4820     */
4821    public function getCSP() {
4822        return $this->CSP;
4823    }
4824
4825    /**
4826     * Sets the output mechanism for content security policies (HTTP headers or meta tags).
4827     * Defaults to HTTP headers; in most cases this should not be changed.
4828     *
4829     * Meta mode should not be used together with setArticleBodyOnly() as meta tags and other
4830     * headers are not output when that flag is set.
4831     *
4832     * @param string $mode One of the CSP_* constants
4833     * @phan-param 'headers'|'meta' $mode
4834     * @return void
4835     * @see self::CSP_HEADERS
4836     * @see self::CSP_META
4837     */
4838    public function setCspOutputMode( string $mode ): void {
4839        $this->cspOutputMode = $mode;
4840    }
4841
4842    /**
4843     * The final bits that go to the bottom of a page
4844     * HTML document including the closing tags
4845     *
4846     * @internal
4847     * @since 1.37
4848     * @param Skin $skin
4849     * @return string
4850     */
4851    public function tailElement( $skin ) {
4852        $tail = [
4853            MWDebug::getDebugHTML( $skin ),
4854            $this->getBottomScripts(),
4855            MWDebug::getHTMLDebugLog(),
4856            Html::closeElement( 'body' ),
4857            Html::closeElement( 'html' ),
4858        ];
4859
4860        return WrappedStringList::join( "\n", $tail );
4861    }
4862}
4863
4864/** @deprecated class alias since 1.41 */
4865class_alias( OutputPage::class, 'OutputPage' );