Completed
Push — master ( 5bc8c9...07e638 )
by Morris
52:34 queued 36:57
created

Factory::respectDefaultLanguage()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 9
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 16
rs 8.8571
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 * @copyright 2016 Roeland Jago Douma <[email protected]>
5
 * @copyright 2016 Lukas Reschke <[email protected]>
6
 *
7
 * @author Bart Visscher <[email protected]>
8
 * @author Joas Schilling <[email protected]>
9
 * @author Lukas Reschke <[email protected]>
10
 * @author Morris Jobke <[email protected]>
11
 * @author Robin Appelman <[email protected]>
12
 * @author Robin McCorkell <[email protected]>
13
 * @author Roeland Jago Douma <[email protected]>
14
 *
15
 * @license AGPL-3.0
16
 *
17
 * This code is free software: you can redistribute it and/or modify
18
 * it under the terms of the GNU Affero General Public License, version 3,
19
 * as published by the Free Software Foundation.
20
 *
21
 * This program is distributed in the hope that it will be useful,
22
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
23
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24
 * GNU Affero General Public License for more details.
25
 *
26
 * You should have received a copy of the GNU Affero General Public License, version 3,
27
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
28
 *
29
 */
30
31
namespace OC\L10N;
32
33
use OCP\IConfig;
34
use OCP\IRequest;
35
use OCP\IUserSession;
36
use OCP\L10N\IFactory;
37
38
/**
39
 * A factory that generates language instances
40
 */
41
class Factory implements IFactory {
42
43
	/** @var string */
44
	protected $requestLanguage = '';
45
46
	/**
47
	 * cached instances
48
	 * @var array Structure: Lang => App => \OCP\IL10N
49
	 */
50
	protected $instances = [];
51
52
	/**
53
	 * @var array Structure: App => string[]
54
	 */
55
	protected $availableLanguages = [];
56
57
	/**
58
	 * @var array Structure: string => callable
59
	 */
60
	protected $pluralFunctions = [];
61
62
	/** @var IConfig */
63
	protected $config;
64
65
	/** @var IRequest */
66
	protected $request;
67
68
	/** @var IUserSession */
69
	protected $userSession;
70
71
	/** @var string */
72
	protected $serverRoot;
73
74
	/**
75
	 * @param IConfig $config
76
	 * @param IRequest $request
77
	 * @param IUserSession $userSession
78
	 * @param string $serverRoot
79
	 */
80
	public function __construct(IConfig $config,
81
								IRequest $request,
82
								IUserSession $userSession,
83
								$serverRoot) {
84
		$this->config = $config;
85
		$this->request = $request;
86
		$this->userSession = $userSession;
87
		$this->serverRoot = $serverRoot;
88
	}
89
90
	/**
91
	 * Get a language instance
92
	 *
93
	 * @param string $app
94
	 * @param string|null $lang
95
	 * @return \OCP\IL10N
96
	 */
97
	public function get($app, $lang = null) {
98
		$app = \OC_App::cleanAppId($app);
99
		if ($lang !== null) {
100
			$lang = str_replace(array('\0', '/', '\\', '..'), '', (string) $lang);
101
		}
102
103
		$forceLang = $this->config->getSystemValue('force_language', false);
104
		if (is_string($forceLang)) {
105
			$lang = $forceLang;
106
		}
107
108
		if ($lang === null || !$this->languageExists($app, $lang)) {
109
			$lang = $this->findLanguage($app);
110
		}
111
112
		if (!isset($this->instances[$lang][$app])) {
113
			$this->instances[$lang][$app] = new L10N(
114
				$this, $app, $lang,
115
				$this->getL10nFilesForApp($app, $lang)
116
			);
117
		}
118
119
		return $this->instances[$lang][$app];
120
	}
121
122
	/**
123
	 * Find the best language
124
	 *
125
	 * @param string|null $app App id or null for core
126
	 * @return string language If nothing works it returns 'en'
127
	 */
128
	public function findLanguage($app = null) {
129
		if ($this->requestLanguage !== '' && $this->languageExists($app, $this->requestLanguage)) {
130
			return $this->requestLanguage;
131
		}
132
133
		/**
134
		 * At this point Nextcloud might not yet be installed and thus the lookup
135
		 * in the preferences table might fail. For this reason we need to check
136
		 * whether the instance has already been installed
137
		 *
138
		 * @link https://github.com/owncloud/core/issues/21955
139
		 */
140
		if($this->config->getSystemValue('installed', false)) {
141
			$userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() :  null;
142
			if(!is_null($userId)) {
143
				$userLang = $this->config->getUserValue($userId, 'core', 'lang', null);
144
			} else {
145
				$userLang = null;
146
			}
147
		} else {
148
			$userId = null;
149
			$userLang = null;
150
		}
151
152
		if ($userLang) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $userLang of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
153
			$this->requestLanguage = $userLang;
154
			if ($this->languageExists($app, $userLang)) {
155
				return $userLang;
156
			}
157
		}
158
159
		try {
160
			// Try to get the language from the Request
161
			$lang = $this->getLanguageFromRequest($app);
162
			if ($userId !== null && $app === null && !$userLang) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $userLang of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
163
				$this->config->setUserValue($userId, 'core', 'lang', $lang);
164
			}
165
			return $lang;
166
		} catch (LanguageNotFoundException $e) {
167
			// Finding language from request failed fall back to default language
168
			$defaultLanguage = $this->config->getSystemValue('default_language', false);
169
			if ($defaultLanguage !== false && $this->languageExists($app, $defaultLanguage)) {
170
				return $defaultLanguage;
171
			}
172
		}
173
174
		// We could not find any language so fall back to english
175
		return 'en';
176
	}
177
178
	/**
179
	 * Find all available languages for an app
180
	 *
181
	 * @param string|null $app App id or null for core
182
	 * @return array an array of available languages
183
	 */
184
	public function findAvailableLanguages($app = null) {
185
		$key = $app;
186
		if ($key === null) {
187
			$key = 'null';
188
		}
189
190
		// also works with null as key
191
		if (!empty($this->availableLanguages[$key])) {
192
			return $this->availableLanguages[$key];
193
		}
194
195
		$available = ['en']; //english is always available
196
		$dir = $this->findL10nDir($app);
197 View Code Duplication
		if (is_dir($dir)) {
198
			$files = scandir($dir);
199
			if ($files !== false) {
200
				foreach ($files as $file) {
201
					if (substr($file, -5) === '.json' && substr($file, 0, 4) !== 'l10n') {
202
						$available[] = substr($file, 0, -5);
203
					}
204
				}
205
			}
206
		}
207
208
		// merge with translations from theme
209
		$theme = $this->config->getSystemValue('theme');
210
		if (!empty($theme)) {
211
			$themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot));
212
213 View Code Duplication
			if (is_dir($themeDir)) {
214
				$files = scandir($themeDir);
215
				if ($files !== false) {
216
					foreach ($files as $file) {
217
						if (substr($file, -5) === '.json' && substr($file, 0, 4) !== 'l10n') {
218
							$available[] = substr($file, 0, -5);
219
						}
220
					}
221
				}
222
			}
223
		}
224
225
		$this->availableLanguages[$key] = $available;
226
		return $available;
227
	}
228
229
	/**
230
	 * @param string|null $app App id or null for core
231
	 * @param string $lang
232
	 * @return bool
233
	 */
234
	public function languageExists($app, $lang) {
235
		if ($lang === 'en') {//english is always available
236
			return true;
237
		}
238
239
		$languages = $this->findAvailableLanguages($app);
240
		return array_search($lang, $languages) !== false;
241
	}
242
243
	/**
244
	 * @param string|null $app
245
	 * @return string
246
	 * @throws LanguageNotFoundException
247
	 */
248
	private function getLanguageFromRequest($app) {
249
		$header = $this->request->getHeader('ACCEPT_LANGUAGE');
250
		if ($header) {
251
			$available = $this->findAvailableLanguages($app);
252
253
			// E.g. make sure that 'de' is before 'de_DE'.
254
			sort($available);
255
256
			$preferences = preg_split('/,\s*/', strtolower($header));
257
			foreach ($preferences as $preference) {
258
				list($preferred_language) = explode(';', $preference);
259
				$preferred_language = str_replace('-', '_', $preferred_language);
260
261
				foreach ($available as $available_language) {
262
					if ($preferred_language === strtolower($available_language)) {
263
						return $this->respectDefaultLanguage($app, $available_language);
264
					}
265
				}
266
267
				// Fallback from de_De to de
268
				foreach ($available as $available_language) {
269
					if (substr($preferred_language, 0, 2) === $available_language) {
270
						return $available_language;
271
					}
272
				}
273
			}
274
		}
275
276
		throw new LanguageNotFoundException();
277
	}
278
279
	/**
280
	 * if default language is set to de_DE (formal German) this should be
281
	 * preferred to 'de' (non-formal German) if possible
282
	 *
283
	 * @param string|null $app
284
	 * @param string $lang
285
	 * @return string
286
	 */
287
	protected function respectDefaultLanguage($app, $lang) {
288
		$result = $lang;
289
		$defaultLanguage = $this->config->getSystemValue('default_language', false);
290
291
		// use formal version of german ("Sie" instead of "Du") if the default
292
		// language is set to 'de_DE' if possible
293
		if (is_string($defaultLanguage) &&
294
			strtolower($lang) === 'de' &&
295
			strtolower($defaultLanguage) === 'de_de' &&
296
			$this->languageExists($app, 'de_DE')
297
		) {
298
			$result = 'de_DE';
299
		}
300
301
		return $result;
302
	}
303
304
	/**
305
	 * Checks if $sub is a subdirectory of $parent
306
	 *
307
	 * @param string $sub
308
	 * @param string $parent
309
	 * @return bool
310
	 */
311
	private function isSubDirectory($sub, $parent) {
312
		// Check whether $sub contains no ".."
313
		if(strpos($sub, '..') !== false) {
314
			return false;
315
		}
316
317
		// Check whether $sub is a subdirectory of $parent
318
		if (strpos($sub, $parent) === 0) {
319
			return true;
320
		}
321
322
		return false;
323
	}
324
325
	/**
326
	 * Get a list of language files that should be loaded
327
	 *
328
	 * @param string $app
329
	 * @param string $lang
330
	 * @return string[]
331
	 */
332
	// FIXME This method is only public, until OC_L10N does not need it anymore,
333
	// FIXME This is also the reason, why it is not in the public interface
334
	public function getL10nFilesForApp($app, $lang) {
335
		$languageFiles = [];
336
337
		$i18nDir = $this->findL10nDir($app);
338
		$transFile = strip_tags($i18nDir) . strip_tags($lang) . '.json';
339
340
		if (($this->isSubDirectory($transFile, $this->serverRoot . '/core/l10n/')
341
				|| $this->isSubDirectory($transFile, $this->serverRoot . '/lib/l10n/')
342
				|| $this->isSubDirectory($transFile, $this->serverRoot . '/settings/l10n/')
343
				|| $this->isSubDirectory($transFile, \OC_App::getAppPath($app) . '/l10n/')
344
			)
345
			&& file_exists($transFile)) {
346
			// load the translations file
347
			$languageFiles[] = $transFile;
348
		}
349
350
		// merge with translations from theme
351
		$theme = $this->config->getSystemValue('theme');
352
		if (!empty($theme)) {
353
			$transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot));
354
			if (file_exists($transFile)) {
355
				$languageFiles[] = $transFile;
356
			}
357
		}
358
359
		return $languageFiles;
360
	}
361
362
	/**
363
	 * find the l10n directory
364
	 *
365
	 * @param string $app App id or empty string for core
366
	 * @return string directory
367
	 */
368
	protected function findL10nDir($app = null) {
369
		if (in_array($app, ['core', 'lib', 'settings'])) {
370
			if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) {
371
				return $this->serverRoot . '/' . $app . '/l10n/';
372
			}
373
		} else if ($app && \OC_App::getAppPath($app) !== false) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $app of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
374
			// Check if the app is in the app folder
375
			return \OC_App::getAppPath($app) . '/l10n/';
376
		}
377
		return $this->serverRoot . '/core/l10n/';
378
	}
379
380
381
	/**
382
	 * Creates a function from the plural string
383
	 *
384
	 * Parts of the code is copied from Habari:
385
	 * https://github.com/habari/system/blob/master/classes/locale.php
386
	 * @param string $string
387
	 * @return string
388
	 */
389
	public function createPluralFunction($string) {
390
		if (isset($this->pluralFunctions[$string])) {
391
			return $this->pluralFunctions[$string];
392
		}
393
394
		if (preg_match( '/^\s*nplurals\s*=\s*(\d+)\s*;\s*plural=(.*)$/u', $string, $matches)) {
395
			// sanitize
396
			$nplurals = preg_replace( '/[^0-9]/', '', $matches[1] );
397
			$plural = preg_replace( '#[^n0-9:\(\)\?\|\&=!<>+*/\%-]#', '', $matches[2] );
398
399
			$body = str_replace(
400
				array( 'plural', 'n', '$n$plurals', ),
401
				array( '$plural', '$n', '$nplurals', ),
402
				'nplurals='. $nplurals . '; plural=' . $plural
403
			);
404
405
			// add parents
406
			// important since PHP's ternary evaluates from left to right
407
			$body .= ';';
408
			$res = '';
409
			$p = 0;
410
			for($i = 0; $i < strlen($body); $i++) {
411
				$ch = $body[$i];
412
				switch ( $ch ) {
413
					case '?':
414
						$res .= ' ? (';
415
						$p++;
416
						break;
417
					case ':':
418
						$res .= ') : (';
419
						break;
420
					case ';':
421
						$res .= str_repeat( ')', $p ) . ';';
422
						$p = 0;
423
						break;
424
					default:
425
						$res .= $ch;
426
				}
427
			}
428
429
			$body = $res . 'return ($plural>=$nplurals?$nplurals-1:$plural);';
430
			$function = create_function('$n', $body);
0 ignored issues
show
Security Best Practice introduced by
The use of create_function is highly discouraged, better use a closure.

create_function can pose a great security vulnerability as it is similar to eval, and could be used for arbitrary code execution. We highly recommend to use a closure instead.

// Instead of
$function = create_function('$a, $b', 'return $a + $b');

// Better use
$function = function($a, $b) { return $a + $b; }
Loading history...
431
			$this->pluralFunctions[$string] = $function;
432
			return $function;
433
		} else {
434
			// default: one plural form for all cases but n==1 (english)
435
			$function = create_function(
0 ignored issues
show
Security Best Practice introduced by
The use of create_function is highly discouraged, better use a closure.

create_function can pose a great security vulnerability as it is similar to eval, and could be used for arbitrary code execution. We highly recommend to use a closure instead.

// Instead of
$function = create_function('$a, $b', 'return $a + $b');

// Better use
$function = function($a, $b) { return $a + $b; }
Loading history...
436
				'$n',
437
				'$nplurals=2;$plural=($n==1?0:1);return ($plural>=$nplurals?$nplurals-1:$plural);'
438
			);
439
			$this->pluralFunctions[$string] = $function;
440
			return $function;
441
		}
442
	}
443
}
444