1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @author Trevor Parscal
20 * @author Roan Kattouw
21 */
22
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\MediaWikiServices;
25
26/**
27 * Context object that contains information about the state of a specific
28 * ResourceLoader web request. Passed around to ResourceLoaderModule methods.
29 *
30 * @ingroup ResourceLoader
31 * @since 1.17
32 */
33class ResourceLoaderContext implements MessageLocalizer {
34	public const DEFAULT_LANG = 'qqx';
35	public const DEFAULT_SKIN = 'fallback';
36
37	protected $resourceLoader;
38	protected $request;
39	protected $logger;
40
41	// Module content vary
42	protected $skin;
43	protected $language;
44	protected $debug;
45	protected $user;
46
47	// Request vary (in addition to cache vary)
48	protected $modules;
49	protected $only;
50	protected $version;
51	protected $raw;
52	protected $image;
53	protected $variant;
54	protected $format;
55
56	protected $direction;
57	protected $hash;
58	protected $userObj;
59	/** @var ResourceLoaderImage|false */
60	protected $imageObj;
61
62	/**
63	 * @param ResourceLoader $resourceLoader
64	 * @param WebRequest $request
65	 */
66	public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) {
67		$this->resourceLoader = $resourceLoader;
68		$this->request = $request;
69		$this->logger = $resourceLoader->getLogger();
70
71		// Optimisation: Use WebRequest::getRawVal() instead of getVal(). We don't
72		// need the slow Language+UTF logic meant for user input here. (f303bb9360)
73
74		// List of modules
75		$modules = $request->getRawVal( 'modules' );
76		$this->modules = $modules ? ResourceLoader::expandModuleNames( $modules ) : [];
77
78		// Various parameters
79		$this->user = $request->getRawVal( 'user' );
80		$this->debug = $request->getRawVal( 'debug' ) === 'true';
81		$this->only = $request->getRawVal( 'only' );
82		$this->version = $request->getRawVal( 'version' );
83		$this->raw = $request->getFuzzyBool( 'raw' );
84
85		// Image requests
86		$this->image = $request->getRawVal( 'image' );
87		$this->variant = $request->getRawVal( 'variant' );
88		$this->format = $request->getRawVal( 'format' );
89
90		$this->skin = $request->getRawVal( 'skin' );
91		$skinnames = Skin::getSkinNames();
92		if ( !$this->skin || !isset( $skinnames[$this->skin] ) ) {
93			// The 'skin' parameter is required. (Not yet enforced.)
94			// For requests without a known skin specified,
95			// use MediaWiki's 'fallback' skin for skin-specific decisions.
96			$this->skin = self::DEFAULT_SKIN;
97		}
98	}
99
100	/**
101	 * Return a dummy ResourceLoaderContext object suitable for passing into
102	 * things that don't "really" need a context.
103	 *
104	 * Use cases:
105	 * - Unit tests (deprecated, create empty instance directly or use RLTestCase).
106	 *
107	 * @return ResourceLoaderContext
108	 */
109	public static function newDummyContext() : ResourceLoaderContext {
110		// This currently creates a non-empty instance of ResourceLoader (all modules registered),
111		// but that's probably not needed. So once that moves into ServiceWiring, this'll
112		// become more like the EmptyResourceLoader class we have in PHPUnit tests, which
113		// is what this should've had originally. If this turns out to be untrue, change to:
114		// `MediaWikiServices::getInstance()->getResourceLoader()` instead.
115		return new self( new ResourceLoader(
116			MediaWikiServices::getInstance()->getMainConfig(),
117			LoggerFactory::getInstance( 'resourceloader' )
118		), new FauxRequest( [] ) );
119	}
120
121	public function getResourceLoader() : ResourceLoader {
122		return $this->resourceLoader;
123	}
124
125	/**
126	 * @deprecated since 1.34 Use ResourceLoaderModule::getConfig instead
127	 * inside module methods. Use ResourceLoader::getConfig elsewhere.
128	 * @return Config
129	 * @codeCoverageIgnore
130	 */
131	public function getConfig() {
132		wfDeprecated( __METHOD__, '1.34' );
133		return $this->getResourceLoader()->getConfig();
134	}
135
136	public function getRequest() : WebRequest {
137		return $this->request;
138	}
139
140	/**
141	 * @deprecated since 1.34 Use ResourceLoaderModule::getLogger instead
142	 * inside module methods. Use ResourceLoader::getLogger elsewhere.
143	 * @since 1.27
144	 * @return \Psr\Log\LoggerInterface
145	 */
146	public function getLogger() {
147		return $this->logger;
148	}
149
150	public function getModules() : array {
151		return $this->modules;
152	}
153
154	public function getLanguage() : string {
155		if ( $this->language === null ) {
156			// Must be a valid language code after this point (T64849)
157			// Only support uselang values that follow built-in conventions (T102058)
158			$lang = $this->getRequest()->getRawVal( 'lang', '' );
159			// Stricter version of RequestContext::sanitizeLangCode()
160			$validBuiltinCode = MediaWikiServices::getInstance()->getLanguageNameUtils()
161				->isValidBuiltInCode( $lang );
162			if ( !$validBuiltinCode ) {
163				// The 'lang' parameter is required. (Not yet enforced.)
164				// If omitted, localise with the dummy language code.
165				$lang = self::DEFAULT_LANG;
166			}
167			$this->language = $lang;
168		}
169		return $this->language;
170	}
171
172	public function getDirection() : string {
173		if ( $this->direction === null ) {
174			$direction = $this->getRequest()->getRawVal( 'dir' );
175			if ( $direction === 'ltr' || $direction === 'rtl' ) {
176				$this->direction = $direction;
177			} else {
178				// Determine directionality based on user language (T8100)
179				$this->direction = MediaWikiServices::getInstance()->getLanguageFactory()
180					->getLanguage( $this->getLanguage() )->getDir();
181			}
182		}
183		return $this->direction;
184	}
185
186	public function getSkin() : string {
187		return $this->skin;
188	}
189
190	/**
191	 * @return string|null
192	 */
193	public function getUser() : ?string {
194		return $this->user;
195	}
196
197	/**
198	 * Get a Message object with context set.  See wfMessage for parameters.
199	 *
200	 * @since 1.27
201	 * @param string|string[]|MessageSpecifier $key Message key, or array of keys,
202	 *   or a MessageSpecifier.
203	 * @param mixed ...$params
204	 * @return Message
205	 */
206	public function msg( $key, ...$params ) : Message {
207		return wfMessage( $key, ...$params )
208			->inLanguage( $this->getLanguage() )
209			// Use a dummy title because there is no real title
210			// for this endpoint, and the cache won't vary on it
211			// anyways.
212			->title( Title::newFromText( 'Dwimmerlaik' ) );
213	}
214
215	/**
216	 * Get the possibly-cached User object for the specified username
217	 *
218	 * @since 1.25
219	 * @return User
220	 */
221	public function getUserObj() : User {
222		if ( $this->userObj === null ) {
223			$username = $this->getUser();
224			if ( $username ) {
225				// Use provided username if valid, fallback to anonymous user
226				$this->userObj = User::newFromName( $username ) ?: new User;
227			} else {
228				// Anonymous user
229				$this->userObj = new User;
230			}
231		}
232
233		return $this->userObj;
234	}
235
236	public function getDebug() : bool {
237		return $this->debug;
238	}
239
240	/**
241	 * @return string|null
242	 */
243	public function getOnly() : ?string {
244		return $this->only;
245	}
246
247	/**
248	 * @see ResourceLoaderModule::getVersionHash
249	 * @see ResourceLoaderClientHtml::makeLoad
250	 * @return string|null
251	 */
252	public function getVersion() : ?string {
253		return $this->version;
254	}
255
256	public function getRaw() : bool {
257		return $this->raw;
258	}
259
260	/**
261	 * @return string|null
262	 */
263	public function getImage() : ?string {
264		return $this->image;
265	}
266
267	/**
268	 * @return string|null
269	 */
270	public function getVariant() : ?string {
271		return $this->variant;
272	}
273
274	/**
275	 * @return string|null
276	 */
277	public function getFormat() : ?string {
278		return $this->format;
279	}
280
281	/**
282	 * If this is a request for an image, get the ResourceLoaderImage object.
283	 *
284	 * @since 1.25
285	 * @return ResourceLoaderImage|bool false if a valid object cannot be created
286	 */
287	public function getImageObj() {
288		if ( $this->imageObj === null ) {
289			$this->imageObj = false;
290
291			if ( !$this->image ) {
292				return $this->imageObj;
293			}
294
295			$modules = $this->getModules();
296			if ( count( $modules ) !== 1 ) {
297				return $this->imageObj;
298			}
299
300			$module = $this->getResourceLoader()->getModule( $modules[0] );
301			if ( !$module || !$module instanceof ResourceLoaderImageModule ) {
302				return $this->imageObj;
303			}
304
305			$image = $module->getImage( $this->image, $this );
306			if ( !$image ) {
307				return $this->imageObj;
308			}
309
310			$this->imageObj = $image;
311		}
312
313		return $this->imageObj;
314	}
315
316	/**
317	 * Return the replaced-content mapping callback
318	 *
319	 * When editing a page that's used to generate the scripts or styles of a
320	 * ResourceLoaderWikiModule, a preview should use the to-be-saved version of
321	 * the page rather than the current version in the database. A context
322	 * supporting such previews should return a callback to return these
323	 * mappings here.
324	 *
325	 * @since 1.32
326	 * @return callable|null Signature is `Content|null func( Title $t )`
327	 */
328	public function getContentOverrideCallback() {
329		return null;
330	}
331
332	public function shouldIncludeScripts() : bool {
333		return $this->getOnly() === null || $this->getOnly() === 'scripts';
334	}
335
336	public function shouldIncludeStyles() : bool {
337		return $this->getOnly() === null || $this->getOnly() === 'styles';
338	}
339
340	public function shouldIncludeMessages() : bool {
341		return $this->getOnly() === null;
342	}
343
344	/**
345	 * All factors that uniquely identify this request, except 'modules'.
346	 *
347	 * The list of modules is excluded here for legacy reasons as most callers already
348	 * split up handling of individual modules. Including it here would massively fragment
349	 * the cache and decrease its usefulness.
350	 *
351	 * E.g. Used by RequestFileCache to form a cache key for storing the reponse output.
352	 *
353	 * @return string
354	 */
355	public function getHash() : string {
356		if ( !isset( $this->hash ) ) {
357			$this->hash = implode( '|', [
358				// Module content vary
359				$this->getLanguage(),
360				$this->getSkin(),
361				$this->getDebug(),
362				$this->getUser(),
363				// Request vary
364				$this->getOnly(),
365				$this->getVersion(),
366				$this->getRaw(),
367				$this->getImage(),
368				$this->getVariant(),
369				$this->getFormat(),
370			] );
371		}
372		return $this->hash;
373	}
374
375	/**
376	 * Get the request base parameters, omitting any defaults.
377	 *
378	 * @internal For use by ResourceLoaderStartUpModule only
379	 * @return string[]
380	 */
381	public function getReqBase() : array {
382		$reqBase = [];
383		if ( $this->getLanguage() !== self::DEFAULT_LANG ) {
384			$reqBase['lang'] = $this->getLanguage();
385		}
386		if ( $this->getSkin() !== self::DEFAULT_SKIN ) {
387			$reqBase['skin'] = $this->getSkin();
388		}
389		if ( $this->getDebug() ) {
390			$reqBase['debug'] = 'true';
391		}
392		return $reqBase;
393	}
394
395	/**
396	 * Wrapper around json_encode that avoids needless escapes,
397	 * and pretty-prints in debug mode.
398	 *
399	 * @internal
400	 * @param mixed $data
401	 * @return string|false JSON string, false on error
402	 */
403	public function encodeJson( $data ) {
404		// Keep output as small as possible by disabling needless escape modes
405		// that PHP uses by default.
406		// However, while most module scripts are only served on HTTP responses
407		// for JavaScript, some modules can also be embedded in the HTML as inline
408		// scripts. This, and the fact that we sometimes need to export strings
409		// containing user-generated content and labels that may genuinely contain
410		// a sequences like "</script>", we need to encode either '/' or '<'.
411		// By default PHP escapes '/'. Let's escape '<' instead which is less common
412		// and allows URLs to mostly remain readable.
413		$jsonFlags = JSON_UNESCAPED_SLASHES |
414			JSON_UNESCAPED_UNICODE |
415			JSON_HEX_TAG |
416			JSON_HEX_AMP;
417		if ( $this->getDebug() ) {
418			$jsonFlags |= JSON_PRETTY_PRINT;
419		}
420		return json_encode( $data, $jsonFlags );
421	}
422}
423