Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.08% covered (success)
97.08%
166 / 171
88.24% covered (success)
88.24%
15 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
I18n
97.08% covered (success)
97.08%
166 / 171
88.24% covered (success)
88.24%
15 / 17
143
0.00% covered (danger)
0.00%
0 / 1
 _
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translate
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
10
 encode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadTranslations
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 _getLanguageFromHttpHeader
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getAvailableLanguages
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getBrowserLanguages
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 _normalizeBrowserLanguages
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
18
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageLabels
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isRtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCopyHotkey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 setLanguageFallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 _getPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 _getPluralForm
88.24% covered (success)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
70.67
 _getMatchingLanguage
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
 _matchLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php declare(strict_types=1);
2/**
3 * PrivateBin
4 *
5 * a zero-knowledge paste bin
6 *
7 * @link      https://github.com/PrivateBin/PrivateBin
8 * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
9 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10 */
11
12namespace PrivateBin;
13
14use AppendIterator;
15use GlobIterator;
16
17use function array_key_exists;
18use function in_array;
19
20/**
21 * I18n
22 *
23 * provides internationalization tools like translation, browser language detection, etc.
24 */
25class I18n
26{
27    /**
28     * language
29     *
30     * @access protected
31     * @static
32     * @var    string
33     */
34    protected static $_language = 'en';
35
36    /**
37     * language fallback
38     *
39     * @access protected
40     * @static
41     * @var    string
42     */
43    protected static $_languageFallback = 'en';
44
45    /**
46     * language labels
47     *
48     * @access protected
49     * @static
50     * @var    array
51     */
52    protected static $_languageLabels = [];
53
54    /**
55     * available languages
56     *
57     * @access protected
58     * @static
59     * @var    array
60     */
61    protected static $_availableLanguages = [];
62
63    /**
64     * path to language files
65     *
66     * @access protected
67     * @static
68     * @var    string
69     */
70    protected static $_path = '';
71
72    /**
73     * translation cache
74     *
75     * @access protected
76     * @static
77     * @var    array
78     */
79    protected static $_translations = [];
80
81    /**
82     * translate a string, alias for translate()
83     *
84     * @access public
85     * @static
86     * @param  string|array $messageId
87     * @param  mixed $args one or multiple parameters injected into placeholders
88     * @return string
89     */
90    public static function _($messageId, ...$args)
91    {
92        return forward_static_call_array('PrivateBin\I18n::translate', func_get_args());
93    }
94
95    /**
96     * translate a string
97     *
98     * @access public
99     * @static
100     * @param  string|array $messageId
101     * @param  mixed $args one or multiple parameters injected into placeholders
102     * @return string
103     */
104    public static function translate($messageId, ...$args)
105    {
106        if (empty($messageId)) {
107            return $messageId;
108        }
109        if (empty(self::$_translations)) {
110            self::loadTranslations();
111        }
112        $messages = $messageId;
113        if (is_array($messageId)) {
114            $messageId = count($messageId) > 1 ? $messageId[1] : $messageId[0];
115        }
116        if (!array_key_exists($messageId, self::$_translations)) {
117            self::$_translations[$messageId] = $messages;
118        }
119        array_unshift($args, $messageId);
120        if (is_array(self::$_translations[$messageId])) {
121            $number = (int) $args[1];
122            $key = self::_getPluralForm($number);
123            $max = count(self::$_translations[$messageId]) - 1;
124            if ($key > $max) {
125                $key = $max;
126            }
127
128            $args[0] = self::$_translations[$messageId][$key];
129            $args[1] = $number;
130        } else {
131            $args[0] = self::$_translations[$messageId];
132        }
133        // encode any non-integer arguments, but not the message itself
134        // The message ID comes from trusted sources (code or translation JSON files),
135        // while parameters may come from untrusted sources and need HTML entity encoding
136        // to prevent XSS attacks when the message is inserted into HTML context
137        $argsCount = count($args);
138        for ($i = 1; $i < $argsCount; ++$i) {
139            if (is_int($args[$i])) {
140                continue;
141            }
142            $args[$i] = self::encode($args[$i]);
143        }
144        return call_user_func_array('sprintf', $args);
145    }
146
147    /**
148     * encode HTML entities for output into an HTML5 document
149     *
150     * @access public
151     * @static
152     * @param  string $string
153     * @return string
154     */
155    public static function encode($string)
156    {
157        return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
158    }
159
160    /**
161     * loads translations
162     *
163     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
164     *
165     * @access public
166     * @static
167     * @throws JsonException
168     */
169    public static function loadTranslations()
170    {
171        $availableLanguages = self::getAvailableLanguages();
172
173        // check if the lang cookie was set and that language exists
174        if (
175            array_key_exists('lang', $_COOKIE) &&
176            ($key = array_search($_COOKIE['lang'], $availableLanguages)) !== false
177        ) {
178            self::$_language = $availableLanguages[$key];
179        }
180        // find a translation file matching the browsers language preferences
181        else {
182            self::$_language = self::_getLanguageFromHttpHeader($availableLanguages);
183        }
184
185        // load translations
186        if (self::$_language === 'en') {
187            self::$_translations = [];
188        } else {
189            $data = file_get_contents(self::_getPath(self::$_language . '.json'));
190            self::$_translations = Json::decode($data);
191        }
192    }
193
194    /**
195     * get languages bsed on the HTTP header
196     *
197     * @access private
198     * @static
199     * @param  array $availableLanguages
200     * @return string
201     */
202    private static function _getLanguageFromHttpHeader($availableLanguages)
203    {
204        if (!array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
205            return self::$_languageFallback;
206        }
207
208        // as per https://stackoverflow.com/a/9051957/5008962 we can use the I10n module
209        // if loaded, but it only returns the primary preference (aka one string)
210        if (function_exists('locale_accept_from_http')) {
211            $detectedBestLanguage = locale_accept_from_http($_SERVER['HTTP_ACCEPT_LANGUAGE']);
212            if (in_array($detectedBestLanguage, $availableLanguages)) {
213                return $detectedBestLanguage;
214            }
215        }
216
217        return self::_getMatchingLanguage(
218            self::_normalizeBrowserLanguages(self::getBrowserLanguages(), $availableLanguages),
219            $availableLanguages
220        );
221    }
222
223    /**
224     * get list of available translations based on files found
225     *
226     * @access public
227     * @static
228     * @return array
229     */
230    public static function getAvailableLanguages()
231    {
232        if (count(self::$_availableLanguages) === 0) {
233            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
234            $languageIterator = new AppendIterator();
235            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
236            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
237            $languageIterator->append(new GlobIterator(self::_getPath('??-??.json'))); // for regional variants like zh-tw
238            foreach ($languageIterator as $file) {
239                $language = $file->getBasename('.json');
240                if ($language !== 'en') {
241                    self::$_availableLanguages[] = $language;
242                }
243            }
244        }
245        return self::$_availableLanguages;
246    }
247
248    /**
249     * detect the clients supported languages and return them ordered by preference
250     *
251     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
252     *
253     * @access public
254     * @static
255     * @return array
256     */
257    public static function getBrowserLanguages()
258    {
259        $languages = [];
260        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
261            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
262            foreach ($languageRanges as $languageRange) {
263                if (preg_match(
264                    '/^(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?$/',
265                    trim($languageRange), $match
266                )) {
267                    if (!isset($match[2])) {
268                        $match[2] = '1.0';
269                    } else {
270                        $match[2] = (string) floatval($match[2]);
271                    }
272                    if (!isset($languages[$match[2]])) {
273                        $languages[$match[2]] = [];
274                    }
275                    $languages[$match[2]][] = strtolower($match[1]);
276                }
277            }
278            krsort($languages);
279        }
280        return $languages;
281    }
282
283    /**
284     * normalize browser language aliases to available locale IDs
285     *
286     * @access protected
287     * @static
288     * @param  array $languages
289     * @param  array $availableLanguages
290     * @return array
291     */
292    protected static function _normalizeBrowserLanguages($languages, $availableLanguages)
293    {
294        // Base zh stays before zh-tw because regional locales are appended after base locales.
295        $hasSimplifiedChinese = in_array('zh', $availableLanguages, true);
296        $hasTraditionalChinese = in_array('zh-tw', $availableLanguages, true);
297        foreach ($languages as $quality => $languageRanges) {
298            foreach ($languageRanges as $index => $languageRange) {
299                $isSimplifiedChinese = $languageRange === 'zh-hans' || str_starts_with($languageRange, 'zh-hans-') ||
300                    $languageRange === 'zh-cn' || str_starts_with($languageRange, 'zh-cn-') ||
301                    $languageRange === 'zh-sg' || str_starts_with($languageRange, 'zh-sg-');
302                $isTraditionalChinese = str_starts_with($languageRange, 'zh-tw-') ||
303                    $languageRange === 'zh-hant' || str_starts_with($languageRange, 'zh-hant-') ||
304                    $languageRange === 'zh-hk' || str_starts_with($languageRange, 'zh-hk-') ||
305                    $languageRange === 'zh-mo' || str_starts_with($languageRange, 'zh-mo-');
306                if ($hasSimplifiedChinese && $isSimplifiedChinese) {
307                    $languages[$quality][$index] = 'zh';
308                } elseif ($hasTraditionalChinese && $isTraditionalChinese) {
309                    $languages[$quality][$index] = 'zh-tw';
310                }
311            }
312        }
313        return $languages;
314    }
315
316    /**
317     * get currently loaded language
318     *
319     * @access public
320     * @static
321     * @return string
322     */
323    public static function getLanguage()
324    {
325        return self::$_language;
326    }
327
328    /**
329     * get list of language labels
330     *
331     * Only for given language codes, otherwise all labels.
332     *
333     * @access public
334     * @static
335     * @param  array $languages
336     * @throws JsonException
337     * @return array
338     */
339    public static function getLanguageLabels($languages = [])
340    {
341        $file = self::_getPath('languages.json');
342        if (count(self::$_languageLabels) === 0 && is_readable($file)) {
343            $data = file_get_contents($file);
344            self::$_languageLabels = Json::decode($data);
345        }
346        if (count($languages) === 0) {
347            return self::$_languageLabels;
348        }
349        return array_intersect_key(self::$_languageLabels, array_flip($languages));
350    }
351
352    /**
353     * determines if the current language is written right-to-left (RTL)
354     *
355     * @access public
356     * @static
357     * @return bool
358     */
359    public static function isRtl()
360    {
361        return in_array(self::$_language, ['ar', 'fa', 'he']);
362    }
363
364    /**
365     * get OS-specific copy hotkey modifier key name based on user agent
366     *
367     * @access public
368     * @static
369     * @return string 'Cmd' on macOS, 'Ctrl' otherwise
370     */
371    public static function getCopyHotkey()
372    {
373        return array_key_exists('HTTP_USER_AGENT', $_SERVER) &&
374            str_contains($_SERVER['HTTP_USER_AGENT'], 'Mac') ? self::_('Cmd') : self::_('Ctrl');
375    }
376
377    /**
378     * set the default language
379     *
380     * @access public
381     * @static
382     * @param  string $lang
383     */
384    public static function setLanguageFallback($lang)
385    {
386        if (in_array($lang, self::getAvailableLanguages())) {
387            self::$_languageFallback = $lang;
388        }
389    }
390
391    /**
392     * get language file path
393     *
394     * @access protected
395     * @static
396     * @param  string $file
397     * @return string
398     */
399    protected static function _getPath($file = '')
400    {
401        if (empty(self::$_path)) {
402            self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
403        }
404        return self::$_path . (empty($file) ? '' : DIRECTORY_SEPARATOR . $file);
405    }
406
407    /**
408     * determines the plural form to use based on current language and given number
409     *
410     * From: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
411     *
412     * @access protected
413     * @static
414     * @param  int $n
415     * @return int
416     */
417    protected static function _getPluralForm($n)
418    {
419        switch (self::$_language) {
420            case 'ar':
421                return $n === 0 ? 0 : ($n === 1 ? 1 : ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : ($n % 100 >= 11 ? 4 : 5))));
422            case 'cs':
423            case 'sk':
424                return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
425            case 'co':
426            case 'fa':
427            case 'fr':
428            case 'oc':
429            case 'tr':
430            case 'zh':
431            case 'zh-tw':
432                return $n > 1 ? 1 : 0;
433            case 'he':
434                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
435            case 'id':
436            case 'ja':
437            case 'jbo':
438            case 'ko':
439            case 'th':
440                return 0;
441            case 'lt':
442                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && ($n % 100 < 10 || $n % 100 >= 20)) ? 1 : 2);
443            case 'pl':
444                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
445            case 'ro':
446                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
447            case 'ru':
448            case 'uk':
449                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
450            case 'sl':
451                return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
452            default:
453                // bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt, sv
454                return $n !== 1 ? 1 : 0;
455        }
456    }
457
458    /**
459     * compares two language preference arrays and returns the preferred match
460     *
461     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
462     *
463     * @access protected
464     * @static
465     * @param  array $acceptedLanguages
466     * @param  array $availableLanguages
467     * @return string
468     */
469    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
470    {
471        $matches = [];
472        $any = false;
473        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
474            $acceptedQuality = floatval($acceptedQuality);
475            if ($acceptedQuality === 0.0) {
476                continue;
477            }
478            foreach ($acceptedValues as $acceptedValue) {
479                if ($acceptedValue === '*') {
480                    $any = true;
481                }
482                foreach ($availableLanguages as $availableValue) {
483                    $availableQuality = 1.0;
484                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
485                    if ($matchingGrade > 0) {
486                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
487                        if (!isset($matches[$q])) {
488                            $matches[$q] = [];
489                        }
490                        if (!in_array($availableValue, $matches[$q])) {
491                            $matches[$q][] = $availableValue;
492                        }
493                    }
494                }
495            }
496        }
497        if (count($matches) === 0 && $any) {
498            if (count($availableLanguages) > 0) {
499                $matches['1.0'] = $availableLanguages;
500            }
501        }
502        if (count($matches) === 0) {
503            return self::$_languageFallback;
504        }
505        krsort($matches);
506        $topmatches = current($matches);
507        return current($topmatches);
508    }
509
510    /**
511     * compare two language IDs and return the degree they match
512     *
513     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
514     *
515     * @access protected
516     * @static
517     * @param  string $a
518     * @param  string $b
519     * @return float
520     */
521    protected static function _matchLanguage($a, $b)
522    {
523        $a = explode('-', $a);
524        $b = explode('-', $b);
525        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
526            if ($a[$i] !== $b[$i]) {
527                break;
528            }
529        }
530        return $i === 0 ? 0 : (float) $i / count($a);
531    }
532}