| File: | blib/lib/CGI/Lingua.pm |
| Coverage: | 80.8% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package CGI::Lingua; | |||||
| 2 | ||||||
| 3 | 28 28 28 | 2027557 21 565 | use warnings; | |||
| 4 | 28 28 28 | 47 17 275 | use strict; | |||
| 5 | 28 28 28 | 3055 106393 49 | use autodie qw(:all); | |||
| 6 | ||||||
| 7 | 28 28 28 | 150080 38 712 | use Carp qw(croak carp); | |||
| 8 | 28 28 28 | 7089 1728337 528 | use Object::Configure 0.23; | |||
| 9 | 28 28 28 | 62 131 422 | use Params::Get 0.15; # 0.15 fast-path: unblessed hashref returned directly | |||
| 10 | 28 28 28 | 48 26 449 | use Readonly; | |||
| 11 | 28 28 28 | 51 26 371 | use Scalar::Util qw(blessed); | |||
| 12 | 28 28 28 | 8549 163631 458 | use JSON::PP (); | |||
| 13 | 28 | 78 | use Class::Autouse qw{ | |||
| 14 | Locale::Language | |||||
| 15 | Locale::Object::Country | |||||
| 16 | Locale::Object::DB | |||||
| 17 | I18N::AcceptLanguage | |||||
| 18 | I18N::LangTags::Detect | |||||
| 19 | 28 28 | 6207 85555 | }; | |||
| 20 | ||||||
| 21 | our $VERSION = '0.84'; | |||||
| 22 | ||||||
| 23 | # ââ Module-level constants âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 24 | # Gathering magic strings here makes behavioural changes one-edit operations. | |||||
| 25 | ||||||
| 26 | Readonly my $CACHE_TTL_LONG => '1 month'; | |||||
| 27 | Readonly my $CACHE_TTL_SHORT => '1 hour'; | |||||
| 28 | Readonly my $CACHE_NS => 'CGI::Lingua:'; # namespace prefix for every key | |||||
| 29 | Readonly my $BROKEN_GEOIPFREE => '45.128.139.41'; # https://github.com/bricas/geo-ipfree/issues/10 | |||||
| 30 | Readonly my $BAIDU_SUBNET => '185.10.104.0/22';# RT-86809: Baidu misreports as EU | |||||
| 31 | Readonly my $DEPRECATED_EN_UK => 'en-uk'; # some browsers still send this | |||||
| 32 | Readonly my $CANONICAL_EN_GB => 'en-gb'; | |||||
| 33 | Readonly my $ACCEPT_LANG_MAX => 256; # max bytes we accept from the header | |||||
| 34 | Readonly my $UA_MAX => 512; # max bytes we accept from HTTP_USER_AGENT | |||||
| 35 | Readonly my $GEO_UNKNOWN => -1; # geo-module sentinel: not yet probed | |||||
| 36 | Readonly my $GEO_ABSENT => 0; # geo-module sentinel: unavailable | |||||
| 37 | Readonly my $GEO_PRESENT => 1; # geo-module sentinel: loaded OK | |||||
| 38 | Readonly my %RTL_LANGS => (map { $_ => 1 } # ISO 639-1 codes whose primary script is RTL | |||||
| 39 | qw(ar dv fa he ku ps sd ug ur yi)); | |||||
| 40 | ||||||
| 41 - 49 | =head1 NAME CGI::Lingua - Create a multilingual web page =head1 VERSION Version 0.84 =cut | |||||
| 50 | ||||||
| 51 - 146 | =head1 SYNOPSIS
CGI::Lingua is a powerful module for multilingual web applications
offering extensive language/country detection strategies.
No longer does your website need to be in English only.
CGI::Lingua provides a simple basis to determine which language to display a website.
The website tells CGI::Lingua which languages it supports.
Based on that list CGI::Lingua tells the application which language the user would like to use.
use CGI::Lingua;
# ...
my $l = CGI::Lingua->new(['en', 'fr', 'en-gb', 'en-us']);
my $language = $l->language();
if ($language eq 'English') {
print '<P>Hello</P>';
} elsif($language eq 'French') {
print '<P>Bonjour</P>';
} else { # $language eq 'Unknown'
my $rl = $l->requested_language();
print "<P>Sorry for now this page is not available in $rl.</P>";
}
my $c = $l->country();
if ($c eq 'us') {
# print contact details in the US
} elsif ($c eq 'ca') {
# print contact details in Canada
} else {
# print worldwide contact details
}
# ...
use CHI;
use CGI::Lingua;
# ...
my $cache = CHI->new(driver => 'File', root_dir => '/tmp/cache', namespace => 'CGI::Lingua-countries');
$l = CGI::Lingua->new({ supported => ['en', 'fr'], cache => $cache });
=head1 SUBROUTINES/METHODS
=head2 new
Creates a CGI::Lingua object.
=head3 API SPECIFICATION
Input:
supported => ArrayRef[Str] | Str # required; RFC-1766 language codes
cache => Object # optional; CHI-compatible (get/set)
config_file => Str # optional; YAML/XML/INI config path
logger => Object # optional; must implement warn/info/error
info => Object # optional; CGI::Info-compatible
data => Any # optional; forwarded to I18N::AcceptLanguage
dont_use_ip => Bool # optional; disable IP-based fallback
syslog => Bool | HashRef # optional; Sys::Syslog integration
debug => Bool # optional; enable debug logging
Returns: CGI::Lingua blessed hashref, or a clone when called on an object.
=head3 EXAMPLE
# Array-ref of supported codes (most common form)
my $l = CGI::Lingua->new({ supported => ['en', 'fr', 'de'] });
# Single scalar code
my $l = CGI::Lingua->new(supported => 'en');
# With cache, logger, and CGI::Info object
use CHI;
my $cache = CHI->new(driver => 'File', root_dir => '/tmp/lingua-cache');
my $l = CGI::Lingua->new({
supported => ['en', 'fr'],
cache => $cache,
logger => $my_log_object,
});
# Clone an existing object with different supported list
my $clone = $l->new(supported => ['de']);
=head3 MESSAGES
"You must give a list of supported languages" - no 'supported' key provided
"List of supported languages must be an array ref" - supported is wrong ref type
"Supported languages must be the short code" - string too short or too long
"Logger must be a blessed object with warn/info/error methods" - bad logger arg
=head3 PSEUDOCODE
1. Normalise args via Params::Get and Object::Configure
2. Validate logger (must be blessed with warn/info/error) if provided
3. Validate supported (required, string or arrayref)
4. If cache and REMOTE_ADDR set, attempt to thaw a previously stored state
5. Bless and return fresh object with sentinel flags set to GEO_UNKNOWN
=cut | |||||
| 147 | ||||||
| 148 | sub new | |||||
| 149 | { | |||||
| 150 | 1699 | 3726370 | my $class = shift; | |||
| 151 | 1699 | 2124 | my $params = Params::Get::get_params('supported', @_); | |||
| 152 | ||||||
| 153 | # Handle ::new() misuse | |||||
| 154 | 1695 | 24455 | if(!defined($class)) { | |||
| 155 | 2 | 3 | if($params) { | |||
| 156 | 2 | 2 | if(my $logger = $params->{'logger'}) { | |||
| 157 | 0 | 0 | $logger->error(__PACKAGE__ . ' use ->new() not ::new() to instantiate'); | |||
| 158 | } | |||||
| 159 | 2 | 10 | croak(__PACKAGE__ . ' use ->new() not ::new() to instantiate'); | |||
| 160 | } | |||||
| 161 | 0 | 0 | $class = __PACKAGE__; | |||
| 162 | } elsif(ref($class)) { | |||||
| 163 | # Clone: overlay new params onto existing object state | |||||
| 164 | 3 | 11 | $params->{_supported} ||= $params->{supported} if defined $params->{'supported'}; | |||
| 165 | 3 3 3 | 2 5 10 | return bless { %{$class}, %{$params} }, ref($class); | |||
| 166 | } | |||||
| 167 | ||||||
| 168 | # Validate blessed logger objects before Object::Configure runs. | |||||
| 169 | # Non-blessed values (arrayrefs, hashrefs) are valid config forms that | |||||
| 170 | # Object::Configure knows how to convert into a Log::Abstraction instance. | |||||
| 171 | 1690 | 1791 | if(defined $params->{'logger'} && blessed($params->{'logger'})) { | |||
| 172 | 7 | 24 | unless( | |||
| 173 | $params->{'logger'}->can('warn') | |||||
| 174 | && $params->{'logger'}->can('info') | |||||
| 175 | && $params->{'logger'}->can('error') | |||||
| 176 | ) { | |||||
| 177 | 3 | 160 | croak('Logger must be a blessed object with warn/info/error methods'); | |||
| 178 | } | |||||
| 179 | } | |||||
| 180 | ||||||
| 181 | 1687 | 2332 | $params = Object::Configure::configure($class, $params); | |||
| 182 | ||||||
| 183 | # Normalise supported / supported_languages alias | |||||
| 184 | 1687 | 3128694 | $params->{'supported'} ||= $params->{'supported_languages'}; | |||
| 185 | 1687 | 2200 | if(defined($params->{supported})) { | |||
| 186 | # Validate supported type/length | |||||
| 187 | 1681 | 2895 | if(ref($params->{supported})) { | |||
| 188 | 650 | 792 | if(ref($params->{supported}) ne 'ARRAY') { | |||
| 189 | 243 | 2751 | croak('List of supported languages must be an array ref'); | |||
| 190 | } | |||||
| 191 | } elsif((length($params->{supported}) < 2) || (length($params->{supported}) > 5)) { | |||||
| 192 | 185 | 2118 | croak('Supported languages must be the short code'); | |||
| 193 | } | |||||
| 194 | } else { | |||||
| 195 | 6 | 10 | if(my $logger = $params->{'logger'}) { | |||
| 196 | 6 | 9 | $logger->error('You must give a list of supported languages'); | |||
| 197 | } | |||||
| 198 | 6 | 1777 | croak('You must give a list of supported languages'); | |||
| 199 | } | |||||
| 200 | ||||||
| 201 | 1253 | 829 | my $cache = $params->{cache}; | |||
| 202 | 1253 | 849 | my $info = $params->{info}; | |||
| 203 | ||||||
| 204 | # Try to restore a frozen state from the cache before doing any work | |||||
| 205 | 1253 | 1082 | if($cache && $ENV{'REMOTE_ADDR'}) { | |||
| 206 | 33 | 45 | my $key = _build_cache_key($ENV{'REMOTE_ADDR'}, $params, $class, $info); | |||
| 207 | 33 | 88 | if(my $frozen = $cache->get($key)) { | |||
| 208 | # JSON::PP is used in preference to Storable::thaw because Storable | |||||
| 209 | # can execute arbitrary Perl code via STORABLE_thaw hooks if an | |||||
| 210 | # attacker manages to write a crafted blob to the cache backend. | |||||
| 211 | # JSON cannot execute code regardless of its content. | |||||
| 212 | # If the blob is not valid JSON (e.g. a legacy Storable entry), the | |||||
| 213 | # eval catches the error and we fall through to fresh construction. | |||||
| 214 | 6 6 6 | 459 9 13 | my $rc = eval { local $SIG{__DIE__}; JSON::PP::decode_json($frozen) }; | |||
| 215 | 6 | 3246 | unless(defined $rc && ref($rc) eq 'HASH') { | |||
| 216 | 0 | 0 | $rc = undef; # stale or corrupt entry â rebuild below | |||
| 217 | } | |||||
| 218 | 6 | 7 | if(defined $rc) { | |||
| 219 | 6 | 6 | bless $rc, $class; | |||
| 220 | # Re-inject transient/non-serialisable fields | |||||
| 221 | 6 | 9 | $rc->{logger} = $params->{'logger'}; | |||
| 222 | 6 | 7 | $rc->{_syslog} = $params->{syslog}; | |||
| 223 | 6 | 6 | $rc->{_cache} = $cache; | |||
| 224 | 6 | 6 | $rc->{_supported} = $params->{supported}; | |||
| 225 | 6 | 6 | $rc->{_info} = $info; | |||
| 226 | 6 | 11 | $rc->{_have_ipcountry} = $GEO_UNKNOWN; | |||
| 227 | 6 | 17 | $rc->{_have_geoip} = $GEO_UNKNOWN; | |||
| 228 | 6 | 12 | $rc->{_have_geoipfree} = $GEO_UNKNOWN; | |||
| 229 | ||||||
| 230 | # If lang= CGI param is active, the cached language choice may be stale | |||||
| 231 | 6 | 28 | if(($rc->{_what_language} || $rc->{_rlanguage}) && $info && $info->lang()) { | |||
| 232 | 0 0 | 0 0 | delete @{$rc}{qw(_what_language _rlanguage _country)}; | |||
| 233 | } | |||||
| 234 | 6 | 16 | return $rc; | |||
| 235 | } | |||||
| 236 | } | |||||
| 237 | } | |||||
| 238 | ||||||
| 239 | return bless { | |||||
| 240 | 1247 | 6290 | %{$params}, | |||
| 241 | _supported => ref($params->{supported}) ? $params->{supported} : [ $params->{'supported'} ], | |||||
| 242 | _cache => $cache, | |||||
| 243 | _info => $info, | |||||
| 244 | _syslog => $params->{syslog}, | |||||
| 245 | _dont_use_ip => $params->{dont_use_ip} || 0, | |||||
| 246 | _have_ipcountry => $GEO_UNKNOWN, | |||||
| 247 | _have_geoip => $GEO_UNKNOWN, | |||||
| 248 | _have_geoipfree => $GEO_UNKNOWN, | |||||
| 249 | 1247 | 1867 | _debug => $params->{debug} || 0, | |||
| 250 | }, $class; | |||||
| 251 | } | |||||
| 252 | ||||||
| 253 | # ââ _build_cache_key ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 254 | # Purpose: Produce a deterministic string key for the per-request cache | |||||
| 255 | # entry stored in new() and DESTROY(). | |||||
| 256 | # Entry: $addr â IP string (not yet taint-checked; used read-only here) | |||||
| 257 | # $params â constructor params hashref | |||||
| 258 | # $class â package name | |||||
| 259 | # $info â optional CGI::Info object | |||||
| 260 | # Exit: A plain string key of the form "ip/lang/lang1/lang2/..." | |||||
| 261 | sub _build_cache_key | |||||
| 262 | { | |||||
| 263 | 74 | 51959 | my ($addr, $params, $class, $info) = @_; | |||
| 264 | ||||||
| 265 | 74 | 84 | my $key = "$addr/"; | |||
| 266 | ||||||
| 267 | # Include the requested language (if determinable) so different | |||||
| 268 | # Accept-Language values get distinct cache slots for the same IP. | |||||
| 269 | 74 | 46 | my $l; | |||
| 270 | 74 | 155 | if($info && ($l = $info->lang())) { | |||
| 271 | 3 | 7 | $key .= "$l/"; | |||
| 272 | } elsif($l = $class->_what_language()) { | |||||
| 273 | 46 | 39 | $key .= "$l/"; | |||
| 274 | } | |||||
| 275 | ||||||
| 276 | # Fix: was ref($params->{'supported'} eq 'ARRAY') â eq was inside ref(), | |||||
| 277 | # so ref() always received a boolean (1 or ''), never the arrayref itself. | |||||
| 278 | # Result: arrayref-supported always fell through to the else branch and | |||||
| 279 | # stringified to 'ARRAY(0x...)' â a different address every request â | |||||
| 280 | # making cache lookups in new() permanently fail. | |||||
| 281 | 74 | 101 | if(ref($params->{'supported'}) eq 'ARRAY') { | |||
| 282 | 73 73 | 49 87 | $key .= join('/', @{$params->{supported}}); | |||
| 283 | } else { | |||||
| 284 | 1 | 1 | $key .= $params->{'supported'}; | |||
| 285 | } | |||||
| 286 | ||||||
| 287 | 74 | 88 | return $key; | |||
| 288 | } | |||||
| 289 | ||||||
| 290 | # Some of the information takes a long time to work out, so cache what we can | |||||
| 291 | sub DESTROY { | |||||
| 292 | 1248 | 276339 | if(defined($^V) && ($^V ge 'v5.14.0')) { | |||
| 293 | 1248 | 1654 | return if ${^GLOBAL_PHASE} eq 'DESTRUCT'; | |||
| 294 | } | |||||
| 295 | 1248 | 5087 | return unless $ENV{'REMOTE_ADDR'}; | |||
| 296 | ||||||
| 297 | 188 | 127 | my $self = shift; | |||
| 298 | 188 | 179 | return unless ref($self); | |||
| 299 | ||||||
| 300 | 188 | 285 | my $cache = $self->{_cache}; | |||
| 301 | 188 | 537 | return unless $cache; | |||
| 302 | ||||||
| 303 | my $key = _build_cache_key( | |||||
| 304 | $ENV{'REMOTE_ADDR'}, | |||||
| 305 | { supported => $self->{_supported} }, | |||||
| 306 | ref($self), | |||||
| 307 | $self->{_info}, | |||||
| 308 | 34 | 77 | ); | |||
| 309 | 34 | 65 | return if $cache->get($key); | |||
| 310 | ||||||
| 311 | 28 | 1068 | $self->_debug("Storing self in cache as $key"); | |||
| 312 | ||||||
| 313 | # Serialise only the computed state â not loggers, file handles, or | |||||
| 314 | # geo-module objects (they are re-initialised on next construction). | |||||
| 315 | # JSON::PP is used instead of Storable so that a compromised cache backend | |||||
| 316 | # cannot deliver a blob that executes code via STORABLE_thaw hooks. | |||||
| 317 | my %state = ( | |||||
| 318 | _slanguage => $self->{_slanguage}, | |||||
| 319 | _slanguage_code_alpha2 => $self->{_slanguage_code_alpha2}, | |||||
| 320 | _sublanguage_code_alpha2 => $self->{_sublanguage_code_alpha2}, | |||||
| 321 | _country => $self->{_country}, | |||||
| 322 | _rlanguage => $self->{_rlanguage}, | |||||
| 323 | _dont_use_ip => $self->{_dont_use_ip}, | |||||
| 324 | _have_ipcountry => $self->{_have_ipcountry}, | |||||
| 325 | _have_geoip => $self->{_have_geoip}, | |||||
| 326 | _have_geoipfree => $self->{_have_geoipfree}, | |||||
| 327 | 28 | 1034 | ); | |||
| 328 | ||||||
| 329 | 28 | 60 | $cache->set($key, JSON::PP::encode_json(\%state), $CACHE_TTL_LONG); | |||
| 330 | } | |||||
| 331 | ||||||
| 332 - 354 | =head2 language
Tells the CGI application in what language to display its messages.
The language is the natural name e.g. 'English' or 'Japanese'.
Sublanguages are handled sensibly, so that if a client requests U.S. English
on a site that only serves British English, language() will return 'English'.
If none of the requested languages is included within the supported lists,
language() returns 'Unknown'.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'fr,en;q=0.9';
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
print $l->language(); # "French"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str - human-readable language name, or 'Unknown'
=cut | |||||
| 355 | ||||||
| 356 | sub language { | |||||
| 357 | 163 | 6429 | my $self = $_[0]; | |||
| 358 | ||||||
| 359 | 163 | 293 | $self->_find_language() unless $self->{_slanguage}; | |||
| 360 | 163 | 913 | return $self->{_slanguage}; | |||
| 361 | } | |||||
| 362 | ||||||
| 363 - 367 | =head2 preferred_language Same as language(). =cut | |||||
| 368 | ||||||
| 369 | sub preferred_language | |||||
| 370 | { | |||||
| 371 | 7 | 23 | my $self = shift; | |||
| 372 | 7 | 11 | return $self->language(@_); | |||
| 373 | } | |||||
| 374 | ||||||
| 375 - 379 | =head2 name Synonym for language, for compatibility with Locale::Object::Language. =cut | |||||
| 380 | ||||||
| 381 | sub name { | |||||
| 382 | 7 | 23 | my $self = $_[0]; | |||
| 383 | 7 | 11 | return $self->language(); | |||
| 384 | } | |||||
| 385 | ||||||
| 386 - 402 | =head2 sublanguage
Tells the CGI what variant to use e.g. 'United Kingdom', or undef if
it can't be determined.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage(); # "United Kingdom"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str | undef
=cut | |||||
| 403 | ||||||
| 404 | sub sublanguage { | |||||
| 405 | 45 | 105 | my $self = $_[0]; | |||
| 406 | ||||||
| 407 | 45 | 62 | $self->_trace('Entered sublanguage'); | |||
| 408 | 45 | 1433 | $self->_find_language() unless $self->{_slanguage}; | |||
| 409 | 45 | 92 | $self->_trace('Leaving sublanguage ', ($self->{_sublanguage} || 'undef')); | |||
| 410 | 45 | 1156 | return $self->{_sublanguage}; | |||
| 411 | } | |||||
| 412 | ||||||
| 413 - 432 | =head2 language_code_alpha2
Gives the two-character representation of the supported language, e.g. 'en'
when you've asked for en-gb.
If none of the requested languages is included within the supported lists,
language_code_alpha2() returns undef.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->language_code_alpha2(); # "en"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str (2 chars) | undef
=cut | |||||
| 433 | ||||||
| 434 | sub language_code_alpha2 { | |||||
| 435 | 61 | 77 | my $self = $_[0]; | |||
| 436 | ||||||
| 437 | 61 | 74 | $self->_trace('Entered language_code_alpha2'); | |||
| 438 | 61 | 2196 | $self->_find_language() unless $self->{_slanguage}; | |||
| 439 | 61 | 90 | $self->_trace('language_code_alpha2 returns ', $self->{_slanguage_code_alpha2}); | |||
| 440 | 61 | 1803 | return $self->{_slanguage_code_alpha2}; | |||
| 441 | } | |||||
| 442 | ||||||
| 443 - 447 | =head2 code_alpha2 Synonym for language_code_alpha2, kept for historical reasons. =cut | |||||
| 448 | ||||||
| 449 | sub code_alpha2 { | |||||
| 450 | 14 | 570 | my $self = $_[0]; | |||
| 451 | 14 | 15 | return $self->language_code_alpha2(); | |||
| 452 | } | |||||
| 453 | ||||||
| 454 - 470 | =head2 sublanguage_code_alpha2
Gives the two-character representation of the supported language, e.g. 'gb'
when you've asked for en-gb, or undef.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage_code_alpha2(); # "gb"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str (2 chars) | undef
=cut | |||||
| 471 | ||||||
| 472 | sub sublanguage_code_alpha2 { | |||||
| 473 | 19 | 1114 | my $self = $_[0]; | |||
| 474 | ||||||
| 475 | 19 | 50 | $self->_find_language() unless $self->{_slanguage}; | |||
| 476 | 19 | 33 | return $self->{_sublanguage_code_alpha2}; | |||
| 477 | } | |||||
| 478 | ||||||
| 479 - 498 | =head2 requested_language
Gives a human-readable rendition of what language the user asked for whether
or not it is supported.
Returns the sublanguage (if appropriate) in parentheses,
e.g. "English (United Kingdom)"
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->requested_language(); # "English (United Kingdom)"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str - e.g. "English (United Kingdom)" or "Unknown"
=cut | |||||
| 499 | ||||||
| 500 | sub requested_language { | |||||
| 501 | 67 | 9518 | my $self = $_[0]; | |||
| 502 | ||||||
| 503 | 67 | 113 | $self->_find_language() unless $self->{_rlanguage}; | |||
| 504 | 67 | 171 | return $self->{_rlanguage}; | |||
| 505 | } | |||||
| 506 | ||||||
| 507 | # ââ _find_language âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 508 | # Purpose: Populate _slanguage, _rlanguage, _sublanguage, and the | |||||
| 509 | # various code fields by working through the detection pipeline: | |||||
| 510 | # Accept-Language header â I18N::AcceptLanguage â IP country. | |||||
| 511 | # Entry: $self->{_slanguage} must be undef (guards repeated calls). | |||||
| 512 | # Exit: $self->{_slanguage} is set to a language name or 'Unknown'. | |||||
| 513 | # Side Effects: Populates _rlanguage, _sublanguage, *_code_alpha2 fields. | |||||
| 514 | sub _find_language | |||||
| 515 | { | |||||
| 516 | 183 | 1117 | my $self = shift; | |||
| 517 | ||||||
| 518 | 183 | 229 | $self->_trace('Entered _find_language'); | |||
| 519 | ||||||
| 520 | 183 | 7395 | $self->{_rlanguage} = 'Unknown'; | |||
| 521 | 183 | 140 | $self->{_slanguage} = 'Unknown'; | |||
| 522 | ||||||
| 523 | 183 | 209 | my $http_accept_language = $self->_what_language(); | |||
| 524 | 183 | 568 | if(defined($http_accept_language)) { | |||
| 525 | $self->_debug( | |||||
| 526 | "language wanted: $http_accept_language, " | |||||
| 527 | . 'languages supported: ' | |||||
| 528 | 148 148 | 134 345 | . join(', ', @{$self->{_supported}} // '') | |||
| 529 | ); | |||||
| 530 | ||||||
| 531 | # Normalise the deprecated en-uk tag that some browsers send | |||||
| 532 | 148 | 4684 | if($http_accept_language eq $DEPRECATED_EN_UK) { | |||
| 533 | 5 | 18 | $self->_debug("Resetting country code to GB for $http_accept_language"); | |||
| 534 | 5 | 147 | $http_accept_language = $CANONICAL_EN_GB; | |||
| 535 | } | |||||
| 536 | ||||||
| 537 | # Run the header through the Accept-Language resolver | |||||
| 538 | 148 | 444 | my ($l, $requested_sublanguage) = | |||
| 539 | $self->_accept_language_match($http_accept_language); | |||||
| 540 | ||||||
| 541 | # Resolve the matched code to a full language/sublanguage | |||||
| 542 | 148 | 172 | if($l) { | |||
| 543 | 125 | 145 | return if $self->_resolve_match($l, $requested_sublanguage, $http_accept_language); | |||
| 544 | } elsif($http_accept_language =~ /;/) { | |||||
| 545 | # e.g. de-DE,de;q=0.9,en-US;q=0.8 and we support none of those | |||||
| 546 | $self->_notice( | |||||
| 547 | __PACKAGE__, ': ', __LINE__, | |||||
| 548 | ": couldn't honour HTTP_ACCEPT_LANGUAGE=$http_accept_language," | |||||
| 549 | . ' supported languages are: ' | |||||
| 550 | 2 2 | 4 22 | . join(',', @{$self->{_supported}}) | |||
| 551 | ); | |||||
| 552 | } | |||||
| 553 | ||||||
| 554 | # Detected slanguage but rlanguage still Unknown â try I18N::LangTags | |||||
| 555 | 25 | 77 | if($self->{_slanguage} && ($self->{_slanguage} ne 'Unknown')) { | |||
| 556 | 0 | 0 | if($self->{_rlanguage} eq 'Unknown') { | |||
| 557 | 0 | 0 | $self->{_rlanguage} = I18N::LangTags::Detect::detect(); | |||
| 558 | } | |||||
| 559 | 0 | 0 | if($self->{_rlanguage}) { | |||
| 560 | 0 | 0 | if(my $resolved = $self->_code2language($self->{_rlanguage})) { | |||
| 561 | 0 | 0 | $self->{_rlanguage} = $resolved; | |||
| 562 | } | |||||
| 563 | 0 | 0 | return; | |||
| 564 | } | |||||
| 565 | } | |||||
| 566 | ||||||
| 567 | # Last-chance: 2-char or xx-xx header where we have no match | |||||
| 568 | 25 | 86 | if( | |||
| 569 | ((!$self->{_rlanguage}) || ($self->{_rlanguage} eq 'Unknown')) | |||||
| 570 | && ((length($http_accept_language) == 2) || ($http_accept_language =~ /^..-..$/)) | |||||
| 571 | ) { | |||||
| 572 | 16 | 19 | $self->{_rlanguage} = $self->_code2language($http_accept_language) || 'Unknown'; | |||
| 573 | } | |||||
| 574 | 25 | 91639 | $self->{_slanguage} = 'Unknown'; | |||
| 575 | } | |||||
| 576 | ||||||
| 577 | 60 | 70 | return if $self->{_dont_use_ip}; | |||
| 578 | ||||||
| 579 | # Fall back to the official language of the visitor's country | |||||
| 580 | 59 | 71 | $self->_find_language_from_ip($http_accept_language); | |||
| 581 | } | |||||
| 582 | ||||||
| 583 | # ââ _accept_language_match ââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 584 | # Purpose: Run I18N::AcceptLanguage strict matching plus two fallback | |||||
| 585 | # left-to-right scan passes against $self->{_supported}. | |||||
| 586 | # Entry: $http_accept_language â validated, untainted Accept-Language value. | |||||
| 587 | # Exit: Returns ($matched_code, $requested_sublanguage) or (undef, undef). | |||||
| 588 | # Side Effects: Logs debug messages. | |||||
| 589 | sub _accept_language_match | |||||
| 590 | { | |||||
| 591 | 148 | 137 | my ($self, $http_accept_language) = @_; | |||
| 592 | ||||||
| 593 | # Suppress I18N::AcceptLanguage's uninitialized-value warnings (RT 74338) | |||||
| 594 | local $SIG{__WARN__} = sub { | |||||
| 595 | 2 | 40 | warn $_[0] unless $_[0] =~ /^Use of uninitialized value/; | |||
| 596 | 148 | 381 | }; | |||
| 597 | 148 | 370 | my $i18n = I18N::AcceptLanguage->new(debug => $self->{_debug}, strict => 1); | |||
| 598 | 148 | 14290 | my $l = $i18n->accepts($http_accept_language, $self->{_supported}); | |||
| 599 | 148 | 10163 | local $SIG{__WARN__} = 'DEFAULT'; | |||
| 600 | ||||||
| 601 | # I18N-AcceptLanguage strict mode can return a sublanguage variant when | |||||
| 602 | # the request contains a sublanguage we don't support; force a retry. | |||||
| 603 | 148 | 679 | if($l && ($http_accept_language =~ /-/) && ($http_accept_language !~ qr/$l/i)) { | |||
| 604 | 3 | 4 | $self->_debug('Forcing fallback'); | |||
| 605 | 3 | 57 | undef $l; | |||
| 606 | } | |||||
| 607 | ||||||
| 608 | 148 | 122 | my $requested_sublanguage; | |||
| 609 | 148 | 143 | if(!$l) { | |||
| 610 | # Sort tokens by q-value once; both scan passes share the ordered list | |||||
| 611 | 28 | 43 | my $sorted = $self->_sorted_tokens($http_accept_language); | |||
| 612 | # First fallback: scan for xx-yy pairs, try base language xx | |||||
| 613 | 28 | 73 | ($l, $requested_sublanguage) = | |||
| 614 | $self->_scan_sublanguage_pairs($i18n, $sorted); | |||||
| 615 | 28 | 40 | if(!$l) { | |||
| 616 | # Second fallback: scan plain tokens without sublanguages | |||||
| 617 | 24 | 41 | $l = $self->_scan_plain_tokens($i18n, $sorted); | |||
| 618 | 24 | 34 | undef $requested_sublanguage if $l; | |||
| 619 | } | |||||
| 620 | } | |||||
| 621 | ||||||
| 622 | 148 | 592 | return ($l, $requested_sublanguage); | |||
| 623 | } | |||||
| 624 | ||||||
| 625 | # ââ _sorted_tokens ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 626 | # Purpose: Parse an Accept-Language header into tokens sorted by | |||||
| 627 | # descending quality value so fallback scans honour q= priority. | |||||
| 628 | # Entry: $header â validated Accept-Language string. | |||||
| 629 | # Exit: Arrayref of [$language_tag, $quality] pairs, highest q first. | |||||
| 630 | sub _sorted_tokens | |||||
| 631 | { | |||||
| 632 | 36 | 248 | my ($self, $header) = @_; | |||
| 633 | 36 | 26 | my @tokens; | |||
| 634 | 36 | 50 | for my $token (split /,/, $header) { | |||
| 635 | 53 | 102 | $token =~ s/^\s+|\s+$//g; | |||
| 636 | 53 | 33 | my $q = 1.0; | |||
| 637 | 53 | 84 | if($token =~ s/;\s*q\s*=\s*(\d+(?:\.\d+)?)//) { | |||
| 638 | 17 | 26 | $q = $1 + 0; | |||
| 639 | } | |||||
| 640 | 53 | 82 | $token =~ s/^\s+|\s+$//g; | |||
| 641 | 53 | 83 | push @tokens, [$token, $q] if length $token; | |||
| 642 | } | |||||
| 643 | 36 21 | 58 33 | return [sort { $b->[1] <=> $a->[1] } @tokens]; | |||
| 644 | } | |||||
| 645 | ||||||
| 646 | # ââ _scan_sublanguage_pairs âââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 647 | # Purpose: Walk q-sorted tokens looking for xx-yy pairs; try accepting | |||||
| 648 | # the base language xx from the supported list. | |||||
| 649 | # Entry: $i18n â I18N::AcceptLanguage instance; | |||||
| 650 | # $sorted â arrayref from _sorted_tokens. | |||||
| 651 | # Exit: ($matched_code, $sublanguage_code) or (undef, undef). | |||||
| 652 | # Side Effects: Debug logging. | |||||
| 653 | sub _scan_sublanguage_pairs | |||||
| 654 | { | |||||
| 655 | 30 | 34 | my ($self, $i18n, $sorted) = @_; | |||
| 656 | ||||||
| 657 | 30 | 51 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for xx-yy pairs'); | |||
| 658 | 30 30 | 939 33 | for my $entry (@{$sorted}) { | |||
| 659 | 39 39 | 196 42 | my ($tag) = @{$entry}; | |||
| 660 | 39 | 78 | next unless $tag =~ /^(..)-(..)$/; | |||
| 661 | 13 | 17 | my ($base, $sub) = ($1, $2); | |||
| 662 | 13 | 29 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $base is supported"); | |||
| 663 | 13 | 1997 | if($i18n->accepts($base, $self->{_supported})) { | |||
| 664 | 5 | 193 | $self->_debug("Fallback to $base as sublanguage $sub is not supported"); | |||
| 665 | 5 | 95 | return ($base, $sub); | |||
| 666 | } | |||||
| 667 | } | |||||
| 668 | 25 | 92 | return (undef, undef); | |||
| 669 | } | |||||
| 670 | ||||||
| 671 | # ââ _scan_plain_tokens ââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 672 | # Purpose: Walk q-sorted tokens that have no sublanguage suffix and try | |||||
| 673 | # accepting each against the supported list. | |||||
| 674 | # Entry: $i18n â I18N::AcceptLanguage instance; | |||||
| 675 | # $sorted â arrayref from _sorted_tokens. | |||||
| 676 | # Exit: Matched code string, or undef. | |||||
| 677 | # Side Effects: Debug logging. | |||||
| 678 | sub _scan_plain_tokens | |||||
| 679 | { | |||||
| 680 | 27 | 37 | my ($self, $i18n, $sorted) = @_; | |||
| 681 | ||||||
| 682 | 27 | 33 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for plain alternatives'); | |||
| 683 | 27 27 | 844 28 | for my $entry (@{$sorted}) { | |||
| 684 | 35 35 | 85 33 | my ($tag) = @{$entry}; | |||
| 685 | 35 | 57 | next if $tag =~ /^..-../; # already tried in the pair scan | |||
| 686 | 29 | 46 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $tag is supported"); | |||
| 687 | 29 | 845 | if($i18n->accepts($tag, $self->{_supported})) { | |||
| 688 | 3 | 31 | $self->_debug("Fallback to $tag as best alternative"); | |||
| 689 | 3 | 75 | return $tag; | |||
| 690 | } | |||||
| 691 | } | |||||
| 692 | 24 | 794 | return; | |||
| 693 | } | |||||
| 694 | ||||||
| 695 | # ââ _resolve_match ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 696 | # Purpose: Given a matched code $l (possibly xx or xx-yy), populate all | |||||
| 697 | # of _slanguage, _rlanguage, _sublanguage and their code fields. | |||||
| 698 | # Entry: $l â 2-char or xx-yy language code; $requested_sublanguage â | |||||
| 699 | # 2-char variety code or undef; $http_accept_language â full header. | |||||
| 700 | # Exit: Returns true (1) if the caller should return immediately. | |||||
| 701 | # Side Effects: Mutates $self->{_slanguage}, _rlanguage, _sublanguage, etc. | |||||
| 702 | sub _resolve_match | |||||
| 703 | { | |||||
| 704 | 126 | 130 | my ($self, $l, $requested_sublanguage, $http_accept_language) = @_; | |||
| 705 | ||||||
| 706 | 126 | 169 | $self->_debug("l: $l"); | |||
| 707 | ||||||
| 708 | 126 | 4267 | if($l !~ /^..-../) { | |||
| 709 | # Base-language match (e.g. 'en') â no sublanguage component | |||||
| 710 | 81 | 106 | return $self->_resolve_base_match($l, $requested_sublanguage, $http_accept_language); | |||
| 711 | } elsif($l =~ /(.+)-(..)$/) { | |||||
| 712 | # Sublanguage match (e.g. 'en-gb') â resolve both language and variant | |||||
| 713 | 45 | 62 | return $self->_resolve_sublanguage_match($l, $1, $2, $http_accept_language); | |||
| 714 | } | |||||
| 715 | 0 | 0 | return 0; | |||
| 716 | } | |||||
| 717 | ||||||
| 718 | # ââ _resolve_base_match âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 719 | # Purpose: Handle the case where a base-language code matched (no hyphen). | |||||
| 720 | # Sets _slanguage, _rlanguage; appends sublanguage name to rlanguage | |||||
| 721 | # when the client requested one we don't support. | |||||
| 722 | # Entry: $l â 2-char code; $requested_sublanguage â optional; $header. | |||||
| 723 | # Exit: 1 to signal caller should return, 0 otherwise. | |||||
| 724 | # Side Effects: Mutates slanguage, rlanguage, slanguage_code_alpha2. | |||||
| 725 | sub _resolve_base_match | |||||
| 726 | { | |||||
| 727 | 83 | 96 | my ($self, $l, $requested_sublanguage, $header) = @_; | |||
| 728 | ||||||
| 729 | 83 | 99 | $self->{_slanguage} = $self->_code2language($l); | |||
| 730 | 83 | 364579 | return 0 unless $self->{_slanguage}; | |||
| 731 | ||||||
| 732 | 79 | 162 | $self->_debug("_slanguage: $self->{_slanguage}"); | |||
| 733 | 79 | 2599 | $self->{_slanguage_code_alpha2} = $l; | |||
| 734 | 79 | 68 | $self->{_rlanguage} = $self->{_slanguage}; | |||
| 735 | ||||||
| 736 | # Attempt to name the sublanguage the client actually asked for | |||||
| 737 | 79 | 44 | my $sl; | |||
| 738 | 79 | 164 | if($header =~ /..-(..)$/) { | |||
| 739 | 8 | 11 | $self->_debug($1); | |||
| 740 | 8 | 242 | $sl = $self->_code2country($1); | |||
| 741 | 8 | 19 | $requested_sublanguage //= $1; | |||
| 742 | } elsif($header =~ /..-([a-z]{2,3})$/i) { | |||||
| 743 | 1 1 | 1 2 | eval { $sl = Locale::Object::Country->new(code_alpha3 => $1) }; | |||
| 744 | 1 | 242 | $self->_info($@) if $@; | |||
| 745 | } | |||||
| 746 | ||||||
| 747 | 79 | 97 | if($sl) { | |||
| 748 | 7 | 11 | $self->{_rlanguage} .= ' (' . $sl->name() . ')'; | |||
| 749 | } elsif($requested_sublanguage) { | |||||
| 750 | 4 | 6 | if(my $c = $self->_code2countryname($requested_sublanguage)) { | |||
| 751 | 2 | 12 | $self->{_rlanguage} .= " ($c)"; | |||
| 752 | } else { | |||||
| 753 | 2 | 2 | $self->{_rlanguage} .= " (Unknown: $requested_sublanguage)"; | |||
| 754 | } | |||||
| 755 | } | |||||
| 756 | 79 | 181 | return 1; | |||
| 757 | } | |||||
| 758 | ||||||
| 759 | # ââ _resolve_sublanguage_match ââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 760 | # Purpose: Handle the case where the full xx-yy code matched in the | |||||
| 761 | # supported list. Resolves the variety name and caches results. | |||||
| 762 | # Entry: $l â full code e.g. 'en-gb'; $alpha2 â 'en'; $variety â 'gb'; | |||||
| 763 | # $header â full Accept-Language value. | |||||
| 764 | # Exit: 1 to signal caller should return, 0 otherwise. | |||||
| 765 | # Side Effects: Mutates _slanguage, _rlanguage, _sublanguage and code fields; | |||||
| 766 | # writes to cache. | |||||
| 767 | sub _resolve_sublanguage_match | |||||
| 768 | { | |||||
| 769 | 45 | 80 | my ($self, $l, $alpha2, $variety, $header) = @_; | |||
| 770 | ||||||
| 771 | 45 | 66 | my $i18n = I18N::AcceptLanguage->new(strict => 1); | |||
| 772 | 45 | 547 | my $accepts = $i18n->accepts($l, $self->{_supported}); | |||
| 773 | 45 | 1810 | $self->_debug("accepts = $accepts"); | |||
| 774 | ||||||
| 775 | 45 | 1317 | if($accepts) { | |||
| 776 | 45 | 58 | $self->_debug("accepts: $accepts"); | |||
| 777 | ||||||
| 778 | 45 | 1210 | if($accepts =~ /\-/) { | |||
| 779 | 45 | 49 | delete $self->{_slanguage}; | |||
| 780 | } else { | |||||
| 781 | # Cache look-up for the base-language name | |||||
| 782 | 0 | 0 | my $from_cache; | |||
| 783 | 0 | 0 | if($self->{_cache}) { | |||
| 784 | 0 | 0 | $from_cache = $self->{_cache}->get($CACHE_NS . "accepts:$accepts"); | |||
| 785 | } | |||||
| 786 | 0 | 0 | my $slanguage; | |||
| 787 | 0 | 0 | if($from_cache) { | |||
| 788 | 0 | 0 | $self->_debug("$accepts is in cache as $from_cache"); | |||
| 789 | 0 | 0 | $slanguage = (split(/=/, $from_cache))[0]; | |||
| 790 | } else { | |||||
| 791 | 0 | 0 | $slanguage = $self->_code2language($accepts); | |||
| 792 | } | |||||
| 793 | ||||||
| 794 | 0 | 0 | if($slanguage) { | |||
| 795 | 0 | 0 | $self->{_slanguage} = $slanguage; | |||
| 796 | ||||||
| 797 | # Normalise deprecated en-uk variety | |||||
| 798 | 0 | 0 | if($variety eq 'uk') { | |||
| 799 | 0 | 0 | $self->_warn({ warning => "Resetting country code to GB for $header" }); | |||
| 800 | 0 | 0 | $variety = 'gb'; | |||
| 801 | } | |||||
| 802 | ||||||
| 803 | 0 | 0 | if(defined(my $c = $self->_code2countryname($variety))) { | |||
| 804 | 0 | 0 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": setting sublanguage to $c"); | |||
| 805 | 0 | 0 | $self->{_sublanguage} = $c; | |||
| 806 | } | |||||
| 807 | 0 | 0 | $self->{_slanguage_code_alpha2} = $accepts; | |||
| 808 | 0 | 0 | $self->{_sublanguage_code_alpha2} = $variety; | |||
| 809 | ||||||
| 810 | 0 | 0 | if($self->{_sublanguage}) { | |||
| 811 | 0 | 0 | $self->{_rlanguage} = "$self->{_slanguage} ($self->{_sublanguage})"; | |||
| 812 | 0 | 0 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": _rlanguage: $self->{_rlanguage}"); | |||
| 813 | } | |||||
| 814 | ||||||
| 815 | 0 | 0 | unless($from_cache) { | |||
| 816 | 0 | 0 | $self->_debug("Set $variety to $slanguage=$accepts"); | |||
| 817 | $self->{_cache}->set( | |||||
| 818 | $CACHE_NS . "accepts:$variety", | |||||
| 819 | "$slanguage=$accepts", | |||||
| 820 | $CACHE_TTL_LONG | |||||
| 821 | 0 | 0 | ) if $self->{_cache}; | |||
| 822 | } | |||||
| 823 | 0 | 0 | return 1; | |||
| 824 | } | |||||
| 825 | } | |||||
| 826 | } | |||||
| 827 | ||||||
| 828 | # Accepts returned something but we couldn't resolve a language name â | |||||
| 829 | # try harder using the variety code directly | |||||
| 830 | 45 | 53 | $self->{_rlanguage} = $self->_code2language($alpha2); | |||
| 831 | 45 | 364673 | $self->_debug("_rlanguage: $self->{_rlanguage}"); | |||
| 832 | ||||||
| 833 | 45 | 1345 | return 0 unless $accepts; | |||
| 834 | ||||||
| 835 | 45 | 59 | $self->_debug("http_accept_language = $header"); | |||
| 836 | 45 | 1225 | $l =~ /(..)-(..)/; | |||
| 837 | 45 | 49 | $variety = lc($2); | |||
| 838 | ||||||
| 839 | # Skip numeric/region codes like en-029 | |||||
| 840 | 45 | 149 | if(($variety =~ /[a-z]{2,3}/) && !defined($self->{_sublanguage})) { | |||
| 841 | 45 | 76 | $self->_get_closest($alpha2, $alpha2); | |||
| 842 | 45 | 75 | $self->_debug("Find the country code for $variety"); | |||
| 843 | ||||||
| 844 | 45 | 1213 | if($variety eq 'uk') { | |||
| 845 | 0 | 0 | $self->_warn({ warning => "Resetting country code to GB for $header" }); | |||
| 846 | 0 | 0 | $variety = 'gb'; | |||
| 847 | } | |||||
| 848 | ||||||
| 849 | 45 | 36 | my ($from_cache, $language_name); | |||
| 850 | 45 | 51 | if($self->{_cache}) { | |||
| 851 | 8 | 13 | $from_cache = $self->{_cache}->get($CACHE_NS . "variety:$variety"); | |||
| 852 | } | |||||
| 853 | ||||||
| 854 | 45 | 441 | if(defined($from_cache)) { | |||
| 855 | 4 | 10 | $self->_debug("$variety is in cache as $from_cache"); | |||
| 856 | # Cache stores "countryname=langcode" (e.g. "United Kingdom=en"). | |||||
| 857 | # Splitting on = gives the country name as the first field. | |||||
| 858 | 4 | 94 | ($language_name) = split(/=/, $from_cache); | |||
| 859 | } else { | |||||
| 860 | 41 | 116 | my $db = Locale::Object::DB->new(); | |||
| 861 | 41 41 | 71353 59 | my @results = @{$db->lookup( | |||
| 862 | table => 'country', | |||||
| 863 | result_column => 'name', | |||||
| 864 | search_column => 'code_alpha2', | |||||
| 865 | value => $variety | |||||
| 866 | )}; | |||||
| 867 | 41 | 4234 | if(defined($results[0])) { | |||
| 868 | 40 40 | 31 54 | eval { $language_name = $self->_code2countryname($variety) }; | |||
| 869 | } else { | |||||
| 870 | 1 | 2 | $self->_debug("Can't find the country code for $variety in Locale::Object::DB"); | |||
| 871 | } | |||||
| 872 | } | |||||
| 873 | ||||||
| 874 | 45 | 280 | if($@ || !defined($language_name)) { | |||
| 875 | 1 | 2 | $self->_warn({ warning => $@ }) if $@; | |||
| 876 | 1 | 2 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': setting sublanguage to Unknown'); | |||
| 877 | 1 | 30 | $self->{_sublanguage} = 'Unknown'; | |||
| 878 | 1 | 3 | $self->_warn({ warning => "Can't determine values for $header" }); | |||
| 879 | } else { | |||||
| 880 | 44 | 65 | $self->{_sublanguage} = $language_name; | |||
| 881 | 44 | 61 | $self->_debug('variety name ', $self->{_sublanguage}); | |||
| 882 | 44 | 1315 | if($self->{_cache} && !defined($from_cache)) { | |||
| 883 | # Store "countryname=langcode" so future cache hits return the country | |||||
| 884 | # name in the first field. Previously this stored the language name | |||||
| 885 | # ("English=en" for en-gb) which was wrong â the cache-hit branch | |||||
| 886 | # split on = and used the first field as the sublanguage (country) name. | |||||
| 887 | 4 | 10 | $self->_debug("Set variety:$variety to $language_name=$self->{_slanguage_code_alpha2}"); | |||
| 888 | $self->{_cache}->set( | |||||
| 889 | 4 | 90 | $CACHE_NS . "variety:$variety", | |||
| 890 | "$language_name=$self->{_slanguage_code_alpha2}", | |||||
| 891 | $CACHE_TTL_LONG | |||||
| 892 | ); | |||||
| 893 | } | |||||
| 894 | } | |||||
| 895 | } | |||||
| 896 | ||||||
| 897 | 45 | 534 | if(defined($self->{_sublanguage})) { | |||
| 898 | 45 | 53 | $self->{_rlanguage} = "$self->{_slanguage} ($self->{_sublanguage})"; | |||
| 899 | 45 | 47 | $self->{_sublanguage_code_alpha2} = $variety; | |||
| 900 | 45 | 121 | return 1; | |||
| 901 | } | |||||
| 902 | 0 | 0 | return 0; | |||
| 903 | } | |||||
| 904 | ||||||
| 905 | # ââ _find_language_from_ip ââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 906 | # Purpose: Fall back to the visitor's IP country when the Accept-Language | |||||
| 907 | # header produced no usable match. Looks up the official language | |||||
| 908 | # of the country and checks it against the supported list. | |||||
| 909 | # Entry: $http_accept_language â may be undef if no header was present. | |||||
| 910 | # Exit: Mutates _slanguage, _rlanguage via _get_closest if a match found. | |||||
| 911 | # Side Effects: Calls country(); may write to cache. | |||||
| 912 | sub _find_language_from_ip | |||||
| 913 | { | |||||
| 914 | 59 | 55 | my ($self, $http_accept_language) = @_; | |||
| 915 | ||||||
| 916 | 59 | 66 | my $country = $self->country(); | |||
| 917 | ||||||
| 918 | # If country() returned nothing, try to derive from the LANG env var | |||||
| 919 | 59 | 89 | if(!defined($country) && (my $c = $self->_what_language())) { | |||
| 920 | 25 | 57 | if($c =~ /^(..)_(..)/) { | |||
| 921 | 2 | 3 | $country = $2; | |||
| 922 | } elsif($c =~ /^(..)$/) { | |||||
| 923 | 14 | 14 | $country = $1; | |||
| 924 | } | |||||
| 925 | } | |||||
| 926 | 59 | 86 | return unless defined $country; | |||
| 927 | ||||||
| 928 | 20 | 29 | $self->_debug("country: $country"); | |||
| 929 | ||||||
| 930 | 20 | 618 | my ($language_name, $language_code2, $from_cache); | |||
| 931 | 20 | 29 | if($self->{_cache}) { | |||
| 932 | 2 | 4 | $from_cache = $self->{_cache}->get($CACHE_NS . 'language_name:' . $country); | |||
| 933 | } | |||||
| 934 | ||||||
| 935 | 20 | 114 | if($from_cache) { | |||
| 936 | 1 | 2 | $self->_debug("$country is in cache as $from_cache"); | |||
| 937 | 1 | 33 | ($language_name, $language_code2) = split(/=/, $from_cache); | |||
| 938 | } else { | |||||
| 939 | 19 | 30 | my $l = $self->_code2country(uc($country)); | |||
| 940 | 19 | 30 | if($l) { | |||
| 941 | 12 | 17 | $l = ($l->languages_official)[0]; | |||
| 942 | 12 | 5771 | if(defined $l) { | |||
| 943 | 12 | 18 | $language_name = $l->name; | |||
| 944 | 12 | 40 | $language_code2 = $l->code_alpha2; | |||
| 945 | 12 | 52 | $self->_debug("Official language: $language_name") if $language_name; | |||
| 946 | } | |||||
| 947 | } | |||||
| 948 | } | |||||
| 949 | ||||||
| 950 | 20 | 454 | my $ip = $ENV{'REMOTE_ADDR'}; | |||
| 951 | 20 | 26 | return unless $language_name; | |||
| 952 | ||||||
| 953 | 13 | 35 | if((!defined($self->{_rlanguage})) || ($self->{_rlanguage} eq 'Unknown')) { | |||
| 954 | 6 | 6 | $self->{_rlanguage} = $language_name; | |||
| 955 | } | |||||
| 956 | ||||||
| 957 | 13 | 31 | unless((exists $self->{_slanguage}) && ($self->{_slanguage} ne 'Unknown')) { | |||
| 958 | 13 | 10 | my $code; | |||
| 959 | ||||||
| 960 | 13 | 42 | if($language_name && $language_code2 && !defined($http_accept_language)) { | |||
| 961 | # Fast-path for search engines that hit with no Accept-Language | |||||
| 962 | 4 | 5 | $self->_debug("Fast assign to $language_code2"); | |||
| 963 | 4 | 117 | $code = $language_code2; | |||
| 964 | } else { | |||||
| 965 | 9 | 20 | $self->_debug("Call language2code on $self->{_rlanguage}"); | |||
| 966 | 9 | 293 | $code = Locale::Language::language2code($self->{_rlanguage}); | |||
| 967 | ||||||
| 968 | 9 | 90432 | unless($code) { | |||
| 969 | 0 | 0 | if($http_accept_language && ($http_accept_language ne $self->{_rlanguage})) { | |||
| 970 | 0 | 0 | $self->_debug("Call language2code on $http_accept_language"); | |||
| 971 | 0 | 0 | $code = Locale::Language::language2code($http_accept_language); | |||
| 972 | } | |||||
| 973 | 0 | 0 | unless($code) { | |||
| 974 | # Norwegian (Nynorsk) â strip the parenthetical qualifier | |||||
| 975 | 0 | 0 | if($self->{_rlanguage} =~ /(.+)\s\(.+/) { | |||
| 976 | 0 | 0 | if((!defined($http_accept_language)) || ($1 ne $self->{_rlanguage})) { | |||
| 977 | 0 | 0 | $self->_debug("Call language2code on $1"); | |||
| 978 | 0 | 0 | $code = Locale::Language::language2code($1); | |||
| 979 | } | |||||
| 980 | } | |||||
| 981 | 0 | 0 | unless($code) { | |||
| 982 | 0 | 0 | $self->_warn({ | |||
| 983 | warning => "Can't determine code from IP $ip for requested language $self->{_rlanguage}" | |||||
| 984 | }); | |||||
| 985 | } | |||||
| 986 | } | |||||
| 987 | } | |||||
| 988 | } | |||||
| 989 | ||||||
| 990 | 13 | 15 | if($code) { | |||
| 991 | 13 | 24 | $self->_get_closest($code, $language_code2); | |||
| 992 | 13 | 15 | unless($self->{_slanguage}) { | |||
| 993 | 0 | 0 | $self->_warn({ | |||
| 994 | warning => "Couldn't determine closest language for $language_name in $self->{_supported}" | |||||
| 995 | }); | |||||
| 996 | } else { | |||||
| 997 | 13 | 20 | $self->_debug("language set to $self->{_slanguage}, code set to $code"); | |||
| 998 | } | |||||
| 999 | } | |||||
| 1000 | } | |||||
| 1001 | ||||||
| 1002 | 13 | 470 | if(!defined($self->{_slanguage_code_alpha2})) { | |||
| 1003 | 8 | 9 | $self->_debug("Can't determine slanguage_code_alpha2"); | |||
| 1004 | } elsif(!defined($from_cache) && $self->{_cache} && defined($self->{_slanguage_code_alpha2})) { | |||||
| 1005 | 1 | 3 | $self->_debug("Set $country to $language_name=$self->{_slanguage_code_alpha2}"); | |||
| 1006 | $self->{_cache}->set( | |||||
| 1007 | 1 | 30 | $CACHE_NS . 'language_name:' . $country, | |||
| 1008 | "$language_name=$self->{_slanguage_code_alpha2}", | |||||
| 1009 | $CACHE_TTL_LONG | |||||
| 1010 | ); | |||||
| 1011 | } | |||||
| 1012 | } | |||||
| 1013 | ||||||
| 1014 | # ââ _get_closest âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1015 | # Purpose: If $language_string matches the base language of any supported | |||||
| 1016 | # entry, set _slanguage and _slanguage_code_alpha2. | |||||
| 1017 | # Entry: $language_string â base code e.g. 'en'; $alpha2 â same or variant. | |||||
| 1018 | # Exit: Mutates _slanguage and _slanguage_code_alpha2 on match. | |||||
| 1019 | sub _get_closest | |||||
| 1020 | { | |||||
| 1021 | 63 | 107 | my ($self, $language_string, $alpha2) = @_; | |||
| 1022 | ||||||
| 1023 | # Map each supported entry to its base language code | |||||
| 1024 | my %base_languages = | |||||
| 1025 | 63 152 63 | 60 329 81 | map { /^(.+)-/ ? ($1 => $_) : ($_ => $_) } @{$self->{_supported}}; | |||
| 1026 | ||||||
| 1027 | 63 | 85 | if(exists $base_languages{$language_string}) { | |||
| 1028 | 53 | 49 | $self->{_slanguage} = $self->{_rlanguage}; | |||
| 1029 | 53 | 71 | $self->{_slanguage_code_alpha2} = $alpha2; | |||
| 1030 | } | |||||
| 1031 | } | |||||
| 1032 | ||||||
| 1033 | # ââ _what_language ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1034 | # Purpose: Return the raw (validated, untainted) Accept-Language string, | |||||
| 1035 | # consulting in priority order: cached value, CGI lang= param, | |||||
| 1036 | # HTTP_ACCEPT_LANGUAGE env var, LANG env var (local/debug mode). | |||||
| 1037 | # Entry: May be called as a class method (no $self->{...} access) or | |||||
| 1038 | # as an object method. | |||||
| 1039 | # Exit: A validated language string, or undef if nothing available. | |||||
| 1040 | # Side Effects: Caches result in $self->{_what_language} on object calls. | |||||
| 1041 | sub _what_language { | |||||
| 1042 | 321 | 2993 | my $self = $_[0]; | |||
| 1043 | ||||||
| 1044 | 321 | 292 | if(ref($self)) { | |||
| 1045 | 248 | 257 | $self->_trace('Entered _what_language'); | |||
| 1046 | 248 | 7796 | if(defined($self->{_what_language})) { | |||
| 1047 | 27 | 33 | $self->_trace('_what_language: returning cached value: ', $self->{_what_language}); | |||
| 1048 | 27 | 856 | return $self->{_what_language}; | |||
| 1049 | } | |||||
| 1050 | 221 | 247 | if(my $info = $self->{_info}) { | |||
| 1051 | 2 | 4 | if(my $rc = $info->lang()) { | |||
| 1052 | 2 | 6 | $self->_trace("_what_language set language to $rc from the lang argument"); | |||
| 1053 | 2 | 62 | return $self->{_what_language} = $rc; | |||
| 1054 | } | |||||
| 1055 | } | |||||
| 1056 | } | |||||
| 1057 | ||||||
| 1058 | 292 | 364 | if(my $raw_lang = $ENV{'HTTP_ACCEPT_LANGUAGE'}) { | |||
| 1059 | # Validate and untaint â RFC 7231 §5.3.5 character set plus * wildcard | |||||
| 1060 | 239 | 497 | if($raw_lang =~ /^([A-Za-z0-9\-,;=.*\s]{1,$ACCEPT_LANG_MAX})$/a) { | |||
| 1061 | 195 | 1129 | my $rc = $1; # untainted | |||
| 1062 | 195 | 198 | if(ref($self)) { | |||
| 1063 | 147 | 290 | return $self->{_what_language} = $rc; | |||
| 1064 | } | |||||
| 1065 | 48 | 92 | return $rc; | |||
| 1066 | } elsif(ref($self)) { | |||||
| 1067 | 44 | 245 | $self->_warn({ warning => 'HTTP_ACCEPT_LANGUAGE contains invalid characters; ignoring' }); | |||
| 1068 | } | |||||
| 1069 | } | |||||
| 1070 | ||||||
| 1071 | 97 | 2610 | if(defined($ENV{'LANG'})) { | |||
| 1072 | # Running locally (debug mode) â derive from system locale. | |||||
| 1073 | # Apply the same untainting discipline as HTTP_ACCEPT_LANGUAGE: only | |||||
| 1074 | # alphanumeric, hyphen, underscore, and dot are legitimate in a POSIX | |||||
| 1075 | # locale name (e.g. "en_US.UTF-8", "de_DE", "ja"). Anything else is | |||||
| 1076 | # either malformed or an injection attempt; discard it silently. | |||||
| 1077 | 15 | 33 | if($ENV{'LANG'} =~ /^([A-Za-z0-9_.\-]{1,$ACCEPT_LANG_MAX})$/a) { | |||
| 1078 | 5 | 110 | my $rc = $1; # untainted | |||
| 1079 | 5 | 8 | if(ref($self)) { | |||
| 1080 | 5 | 12 | return $self->{_what_language} = $rc; | |||
| 1081 | } | |||||
| 1082 | 0 | 0 | return $rc; | |||
| 1083 | } elsif(ref($self)) { | |||||
| 1084 | 10 | 94 | $self->_warn({ warning => 'LANG contains invalid characters; ignoring' }); | |||
| 1085 | } | |||||
| 1086 | } | |||||
| 1087 | 92 | 1354 | return; | |||
| 1088 | } | |||||
| 1089 | ||||||
| 1090 - 1141 | =head2 country
Returns the two-character country code of the remote end in lowercase.
If L<IP::Country>, L<Geo::IPfree> or L<Geo::IP> is installed,
CGI::Lingua will make use of that, otherwise, it will do a Whois lookup.
If you do not have any of those installed I recommend you use the
caching capability of CGI::Lingua.
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str (2 lowercase chars) | undef
'Unknown' is only returned in the Baidu-EU special case via _handle_eu_country.
=head3 EXAMPLE
# With mod_geoip (fastest - no IP lookup at all):
local $ENV{GEOIP_COUNTRY_CODE} = 'DE';
print $l->country(); # "de"
# With REMOTE_ADDR and IP::Country installed:
local $ENV{REMOTE_ADDR} = '8.8.8.8';
print $l->country(); # "us" (depends on geo database)
=head3 MESSAGES
"GEOIP_COUNTRY_CODE contains an invalid country code; ignoring"
"HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring"
"X.X.X.X isn't a valid IP address"
"Can't determine country from LAN connection X"
"Can't determine country from loopback connection X"
"cache contains a numeric country: N"
"IP matches to a numeric country"
=head3 PSEUDOCODE
1. Return cached _country if set
2. Check GEOIP_COUNTRY_CODE env var (mod_geoip); validate /^[A-Z]{2}$/
3. Check HTTP_CF_IPCOUNTRY (Cloudflare); skip 'XX'; validate /^[A-Z]{2}$/
4. Untaint and validate REMOTE_ADDR; return undef if absent or invalid
5. Skip private and loopback IPs (return undef)
6. Check CHI cache; return cached value if present
7. Try IP::Country::Fast (local DB, fastest)
8. Try Geo::IP (local DB)
9. Try Geo::IPfree (local DB, skip $BROKEN_GEOIPFREE)
10. Try geoplugin.net JSON API (LWP::Simple::WithCache or LWP::Simple)
11. Last resort: Net::Whois::IP then Net::Whois::IANA
12. Sanitise: discard numeric, normalise HK->CN, handle EU special case
13. Store in CHI cache; return result
=cut | |||||
| 1142 | ||||||
| 1143 | sub country { | |||||
| 1144 | 192 | 2677 | my $self = shift; | |||
| 1145 | ||||||
| 1146 | 192 | 232 | $self->_trace(__PACKAGE__, ': Entered country()'); | |||
| 1147 | ||||||
| 1148 | # Return cached result immediately if a previous call already resolved it. | |||||
| 1149 | # Note: undef results (private/loopback IPs) are NOT cached here because | |||||
| 1150 | # country() reads REMOTE_ADDR at call time, not construction time; caching | |||||
| 1151 | # undef would give wrong answers if REMOTE_ADDR changes between calls on | |||||
| 1152 | # the same object (the documented lazy-read design). See LIMITATIONS. | |||||
| 1153 | 192 | 6976 | if($self->{_country}) { | |||
| 1154 | 7 | 9 | $self->_trace('quick return: ', $self->{_country}); | |||
| 1155 | 7 | 230 | return $self->{_country}; | |||
| 1156 | } | |||||
| 1157 | ||||||
| 1158 | # mod_geoip: validate against ISO 3166-1 alpha-2 before trusting | |||||
| 1159 | 185 | 182 | if(defined($ENV{'GEOIP_COUNTRY_CODE'})) { | |||
| 1160 | 28 | 56 | if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})$/a) { | |||
| 1161 | 9 | 15 | $self->{_country} = lc($1); | |||
| 1162 | 9 | 14 | return $self->{_country}; | |||
| 1163 | } else { | |||||
| 1164 | 19 | 29 | $self->_warn({ warning => 'GEOIP_COUNTRY_CODE contains an invalid country code; ignoring' }); | |||
| 1165 | } | |||||
| 1166 | } | |||||
| 1167 | ||||||
| 1168 | # Cloudflare: 'XX' means Cloudflare couldn't determine country â skip it | |||||
| 1169 | 176 | 489 | if(($ENV{'HTTP_CF_IPCOUNTRY'}) && ($ENV{'HTTP_CF_IPCOUNTRY'} ne 'XX')) { | |||
| 1170 | 9 | 22 | if($ENV{'HTTP_CF_IPCOUNTRY'} =~ /^([A-Z]{2})$/a) { | |||
| 1171 | 5 | 10 | $self->{_country} = lc($1); | |||
| 1172 | 5 | 13 | return $self->{_country}; | |||
| 1173 | } else { | |||||
| 1174 | 4 | 9 | $self->_warn({ warning => 'HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring' }); | |||
| 1175 | } | |||||
| 1176 | } | |||||
| 1177 | ||||||
| 1178 | 171 | 141 | my $raw_ip = $ENV{'REMOTE_ADDR'}; | |||
| 1179 | 171 | 191 | return unless defined $raw_ip; | |||
| 1180 | ||||||
| 1181 | # Validate and untaint the IP address before passing to any geo module | |||||
| 1182 | 135 | 75 | my $ip; | |||
| 1183 | 135 | 340 | if($raw_ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/a) { | |||
| 1184 | 112 | 111 | $ip = $1; # untainted IPv4 | |||
| 1185 | } elsif($raw_ip =~ /^([0-9a-fA-F:]{2,39}|[0-9a-fA-F:]{2,30}:(?:\d{1,3}\.){3}\d{1,3})$/a) { | |||||
| 1186 | 7 | 10 | $ip = $1; # untainted IPv6, including mixed notation (e.g. ::ffff:192.0.2.1) | |||
| 1187 | } else { | |||||
| 1188 | 16 | 34 | $self->_warn({ warning => "$raw_ip isn't a valid IP address" }); | |||
| 1189 | 16 | 585 | return; | |||
| 1190 | } | |||||
| 1191 | ||||||
| 1192 | 119 | 1555 | require Data::Validate::IP; | |||
| 1193 | 119 | 102454 | Data::Validate::IP->import(); | |||
| 1194 | ||||||
| 1195 | 119 | 151 | if(!is_ipv4($ip)) { | |||
| 1196 | 8 | 58 | $self->_debug("$ip isn't IPv4. Is it IPv6?"); | |||
| 1197 | 8 | 279 | if($ip eq '::1') { | |||
| 1198 | 4 | 7 | $ip = '127.0.0.1'; # normalise loopback | |||
| 1199 | } elsif($ip =~ /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i) { | |||||
| 1200 | 3 | 4 | $ip = $1; # normalise IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 | |||
| 1201 | # \d{1,3} matches 0-999; validate range before geo lookups because | |||||
| 1202 | # some inet_aton implementations wrap out-of-range octets modulo 256, | |||||
| 1203 | # turning 999.999.999.999 into a real routable address. | |||||
| 1204 | 3 | 4 | unless(is_ipv4($ip)) { | |||
| 1205 | 1 | 8 | $self->_warn({ warning => "$ip isn't a valid IP address" }); | |||
| 1206 | 1 | 140 | return; | |||
| 1207 | } | |||||
| 1208 | } elsif(!is_ipv6($ip)) { | |||||
| 1209 | 1 | 9 | $self->_warn({ warning => "$ip isn't a valid IP address" }); | |||
| 1210 | 1 | 2 | return; | |||
| 1211 | } | |||||
| 1212 | } | |||||
| 1213 | 117 | 2194 | if(is_private_ip($ip)) { | |||
| 1214 | 3 | 101 | $self->_debug("Can't determine country from LAN connection $ip"); | |||
| 1215 | 3 | 100 | return; | |||
| 1216 | } | |||||
| 1217 | 114 | 6762 | if(is_loopback_ip($ip)) { | |||
| 1218 | 70 | 2154 | $self->_debug("Can't determine country from loopback connection $ip"); | |||
| 1219 | 70 | 2507 | return; | |||
| 1220 | } | |||||
| 1221 | ||||||
| 1222 | # Cache look-up â skip for LAN/loopback (already returned above) | |||||
| 1223 | 44 | 2053 | if($self->{_cache}) { | |||
| 1224 | 14 | 31 | $self->{_country} = $self->{_cache}->get($CACHE_NS . "country:$ip"); | |||
| 1225 | 14 | 676 | if(defined($self->{_country})) { | |||
| 1226 | 6 | 11 | if($self->{_country} !~ /\D/) { | |||
| 1227 | 5 | 18 | $self->_warn({ warning => 'cache contains a numeric country: ' . $self->{_country} }); | |||
| 1228 | 5 | 129 | $self->{_cache}->remove($CACHE_NS . "country:$ip"); | |||
| 1229 | 5 | 189 | delete $self->{_country}; | |||
| 1230 | } else { | |||||
| 1231 | 1 | 3 | $self->_debug("Get $ip from cache = $self->{_country}"); | |||
| 1232 | 1 | 53 | return $self->{_country}; | |||
| 1233 | } | |||||
| 1234 | } | |||||
| 1235 | 13 | 24 | $self->_debug("$ip isn't in the cache"); | |||
| 1236 | } | |||||
| 1237 | ||||||
| 1238 | # Try IP::Country first (fastest, local database) | |||||
| 1239 | 43 | 519 | if($self->{_have_ipcountry} == $GEO_UNKNOWN) { | |||
| 1240 | 0 0 | 0 0 | if(eval { require IP::Country::Fast }) { | |||
| 1241 | # Require the concrete class directly; IP::Country->import() is not needed | |||||
| 1242 | # because IP::Country::Fast->new() is a fully-qualified class method call. | |||||
| 1243 | 0 | 0 | $self->{_have_ipcountry} = $GEO_PRESENT; | |||
| 1244 | 0 | 0 | $self->{_ipcountry} = IP::Country::Fast->new(); | |||
| 1245 | } else { | |||||
| 1246 | 0 | 0 | $self->{_have_ipcountry} = $GEO_ABSENT; | |||
| 1247 | } | |||||
| 1248 | } | |||||
| 1249 | 43 | 146 | $self->_debug("have_ipcountry $self->{_have_ipcountry}"); | |||
| 1250 | ||||||
| 1251 | 43 | 1464 | if($self->{_have_ipcountry}) { | |||
| 1252 | 26 | 41 | $self->{_country} = $self->{_ipcountry}->inet_atocc($ip); | |||
| 1253 | 26 | 63 | if($self->{_country}) { | |||
| 1254 | 24 | 29 | $self->{_country} = lc($self->{_country}); | |||
| 1255 | } elsif(is_ipv4($ip)) { | |||||
| 1256 | 2 | 16 | $self->_debug("$ip is not known by IP::Country"); | |||
| 1257 | } | |||||
| 1258 | } | |||||
| 1259 | ||||||
| 1260 | # Try Geo::IP if IP::Country gave nothing | |||||
| 1261 | 43 | 106 | unless(defined($self->{_country})) { | |||
| 1262 | 18 | 21 | if($self->{_have_geoip} == $GEO_UNKNOWN) { | |||
| 1263 | 1 | 4 | $self->_load_geoip(); | |||
| 1264 | } | |||||
| 1265 | 18 | 50 | if($self->{_have_geoip} == $GEO_PRESENT) { | |||
| 1266 | 1 | 4 | $self->{_country} = $self->{_geoip}->country_code_by_addr($ip); | |||
| 1267 | } | |||||
| 1268 | ||||||
| 1269 | # Geo::IPfree has a known-broken entry for $BROKEN_GEOIPFREE | |||||
| 1270 | 18 | 57 | if(!defined($self->{_country}) && ($ip ne $BROKEN_GEOIPFREE)) { | |||
| 1271 | 17 | 62 | if($self->{_have_geoipfree} == $GEO_UNKNOWN) { | |||
| 1272 | 0 0 | 0 0 | eval { require Geo::IPfree }; | |||
| 1273 | 0 | 0 | unless($@) { | |||
| 1274 | # No ->import(): Geo::IPfree uses only OO (->LookUp); the old | |||||
| 1275 | # Geo::IPfree::IP->import() call was a wrong package and could | |||||
| 1276 | # clobber Test::Mockingbird mocks on some versions. | |||||
| 1277 | 0 | 0 | $self->{_have_geoipfree} = $GEO_PRESENT; | |||
| 1278 | 0 | 0 | $self->{_geoipfree} = Geo::IPfree->new(); | |||
| 1279 | } else { | |||||
| 1280 | 0 | 0 | $self->{_have_geoipfree} = $GEO_ABSENT; | |||
| 1281 | } | |||||
| 1282 | } | |||||
| 1283 | 17 | 41 | if($self->{_have_geoipfree} == $GEO_PRESENT) { | |||
| 1284 | 1 | 3 | if(my $country = ($self->{_geoipfree}->LookUp($ip))[0]) { | |||
| 1285 | 1 | 3 | $self->{_country} = lc($country); | |||
| 1286 | } | |||||
| 1287 | } | |||||
| 1288 | } | |||||
| 1289 | } | |||||
| 1290 | ||||||
| 1291 | # 'eu' is not a real country â discard | |||||
| 1292 | 43 | 102 | if($self->{_country} && ($self->{_country} eq 'eu')) { | |||
| 1293 | 2 | 2 | delete $self->{_country}; | |||
| 1294 | } | |||||
| 1295 | ||||||
| 1296 | # Remote JSON lookup via geoplugin | |||||
| 1297 | 43 | 53 | if((!$self->{_country}) && | |||
| 1298 | 19 0 | 824 0 | (eval { require LWP::Simple::WithCache; require JSON::Parse })) { | |||
| 1299 | 0 | 0 | $self->_debug("Look up $ip on geoplugin"); | |||
| 1300 | ||||||
| 1301 | 0 | 0 | if(my $data = LWP::Simple::WithCache::get("https://www.geoplugin.net/json.gp?ip=$ip")) { | |||
| 1302 | 0 0 | 0 0 | eval { $self->{_country} = JSON::Parse::parse_json($data)->{'geoplugin_countryCode'} }; | |||
| 1303 | 0 | 0 | $self->_warn({ warning => "geoplugin returned unparseable JSON: $@" }) if $@; | |||
| 1304 | } | |||||
| 1305 | } | |||||
| 1306 | ||||||
| 1307 | # Last resort: Whois | |||||
| 1308 | 43 | 127 | unless($self->{_country}) { | |||
| 1309 | 19 | 30 | $self->_resolve_country_via_whois($ip); | |||
| 1310 | } | |||||
| 1311 | ||||||
| 1312 | # Sanitise and normalise whatever we found | |||||
| 1313 | 43 | 52 | if($self->{_country}) { | |||
| 1314 | 29 | 49 | if($self->{_country} !~ /\D/) { | |||
| 1315 | 2 | 4 | $self->_warn({ warning => 'IP matches to a numeric country' }); | |||
| 1316 | 2 | 118 | delete $self->{_country}; | |||
| 1317 | } else { | |||||
| 1318 | 27 | 30 | $self->{_country} = lc($self->{_country}); | |||
| 1319 | ||||||
| 1320 | # Legacy mappings | |||||
| 1321 | 27 | 39 | if($self->{_country} eq 'hk') { | |||
| 1322 | 3 | 4 | $self->{_country} = 'cn'; # HK is no longer a separate country in Whois | |||
| 1323 | } elsif($self->{_country} eq 'eu') { | |||||
| 1324 | 2 | 3 | $self->_handle_eu_country($ip); | |||
| 1325 | } | |||||
| 1326 | ||||||
| 1327 | 27 | 144 | if($self->{_country} && ($self->{_country} !~ /\D/)) { | |||
| 1328 | 0 | 0 | $self->_warn({ warning => "cache contains a numeric country: $self->{_country}" }); | |||
| 1329 | 0 | 0 | delete $self->{_country}; | |||
| 1330 | } elsif($self->{_country} && | |||||
| 1331 | $self->{_country} ne 'Unknown' && | |||||
| 1332 | ($self->{_country} !~ /^[a-z]{2}$/)) { | |||||
| 1333 | # Reject anything that is not exactly 2 lowercase ASCII letters, | |||||
| 1334 | # unless it is the 'Unknown' sentinel written by _handle_eu_country | |||||
| 1335 | # for EU addresses that do not map to a specific country. | |||||
| 1336 | # Guards against Whois CRLF injection leftovers ("gbx-header: evil") | |||||
| 1337 | # and XSS payloads in JSON API responses ("gb<script>...</script>"). | |||||
| 1338 | 0 | 0 | $self->_warn({ warning => "Discarding malformed country code '$self->{_country}'" }); | |||
| 1339 | 0 | 0 | delete $self->{_country}; | |||
| 1340 | } elsif($self->{_country} && $self->{_cache}) { | |||||
| 1341 | 7 | 12 | $self->_debug("Set $ip to $self->{_country}"); | |||
| 1342 | $self->{_cache}->set( | |||||
| 1343 | $CACHE_NS . "country:$ip", | |||||
| 1344 | $self->{_country}, | |||||
| 1345 | 7 | 214 | $CACHE_TTL_SHORT | |||
| 1346 | ); | |||||
| 1347 | } | |||||
| 1348 | } | |||||
| 1349 | } | |||||
| 1350 | ||||||
| 1351 | 43 | 909 | return $self->{_country}; | |||
| 1352 | } | |||||
| 1353 | ||||||
| 1354 | # ââ _resolve_country_via_whois âââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1355 | # Purpose: Attempt Net::Whois::IP then Net::Whois::IANA as a last resort. | |||||
| 1356 | # Entry: $ip â validated, untainted IP string. | |||||
| 1357 | # Exit: Sets $self->{_country} if a result was found. | |||||
| 1358 | # Side Effects: Network I/O; logs debug messages. | |||||
| 1359 | sub _resolve_country_via_whois | |||||
| 1360 | { | |||||
| 1361 | 15 | 114 | my ($self, $ip) = @_; | |||
| 1362 | ||||||
| 1363 | 15 | 46 | $self->_debug("Look up $ip on Whois"); | |||
| 1364 | ||||||
| 1365 | 15 | 872 | require Net::Whois::IP; | |||
| 1366 | # No ->import(): whoisip_query is called fully-qualified, so import is unneeded | |||||
| 1367 | # and on some versions reinstalls the real function, clobbering Test::Mockingbird mocks. | |||||
| 1368 | ||||||
| 1369 | 15 | 6641 | my $whois; | |||
| 1370 | 15 | 9 | eval { | |||
| 1371 | # Catch connection timeouts by converting Carp::carp into a die | |||||
| 1372 | 15 1 | 38 9 | local $SIG{__WARN__} = sub { die $_[0] }; | |||
| 1373 | 15 | 17 | $whois = Net::Whois::IP::whoisip_query($ip); | |||
| 1374 | }; | |||||
| 1375 | ||||||
| 1376 | 15 | 1135797 | unless($@ || !defined($whois) || (ref($whois) ne 'HASH')) { | |||
| 1377 | 12 | 15 | if(defined($whois->{Country})) { | |||
| 1378 | 11 | 22 | $self->{_country} = $whois->{Country}; | |||
| 1379 | } elsif(defined($whois->{country})) { | |||||
| 1380 | 1 | 2 | $self->{_country} = $whois->{country}; | |||
| 1381 | } | |||||
| 1382 | 12 | 14 | if($self->{_country}) { | |||
| 1383 | 12 | 26 | if($self->{_country} eq 'EU') { | |||
| 1384 | 2 | 2 | delete $self->{_country}; | |||
| 1385 | } elsif(($self->{_country} eq 'US') && defined($whois->{'StateProv'}) && ($whois->{'StateProv'} eq 'PR')) { | |||||
| 1386 | # RT#131347: Puerto Rico is not the US | |||||
| 1387 | 1 | 2 | $self->{_country} = 'pr'; | |||
| 1388 | } | |||||
| 1389 | } | |||||
| 1390 | } | |||||
| 1391 | ||||||
| 1392 | 15 | 20 | if($self->{_country}) { | |||
| 1393 | 10 | 19 | $self->_debug("Found $ip on Net::Whois::IP as ", $self->{_country}); | |||
| 1394 | 10 | 405 | $self->{_country} = _clean_country_code($self->{_country}); | |||
| 1395 | # _clean_country_code returns undef for malformed values (e.g. CRLF | |||||
| 1396 | # injection leftovers); if so, fall through to the IANA look-up. | |||||
| 1397 | 10 | 29 | return if defined $self->{_country}; | |||
| 1398 | 1 | 1 | delete $self->{_country}; | |||
| 1399 | } | |||||
| 1400 | ||||||
| 1401 | 6 | 9 | $self->_debug("Look up $ip on IANA"); | |||
| 1402 | ||||||
| 1403 | 6 | 420 | require Net::Whois::IANA; | |||
| 1404 | # No ->import(): Net::Whois::IANA->new() is a class method; import not needed. | |||||
| 1405 | ||||||
| 1406 | 6 | 5095 | my $iana = Net::Whois::IANA->new(); | |||
| 1407 | 6 6 | 11 9 | eval { $iana->whois_query(-ip => $ip) }; | |||
| 1408 | 6 | 132515 | unless($@) { | |||
| 1409 | 6 | 9 | $self->{_country} = $iana->country(); | |||
| 1410 | 6 | 21 | $self->_debug("IANA reports $ip as ", $self->{_country}); | |||
| 1411 | } | |||||
| 1412 | ||||||
| 1413 | 6 | 207 | if($self->{_country}) { | |||
| 1414 | 5 | 8 | $self->{_country} = _clean_country_code($self->{_country}); | |||
| 1415 | 5 | 18 | delete $self->{_country} unless defined $self->{_country}; | |||
| 1416 | } | |||||
| 1417 | } | |||||
| 1418 | ||||||
| 1419 | # ââ _clean_country_code âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1420 | # Purpose: Strip carriage returns and trailing "#â¦" comments that some | |||||
| 1421 | # Whois servers append to their country field | |||||
| 1422 | # (e.g. "US\r", "GB # United Kingdom"). | |||||
| 1423 | # Entry: $raw â raw country string from a Whois response. | |||||
| 1424 | # Exit: Cleaned 2-char country code string. | |||||
| 1425 | sub _clean_country_code | |||||
| 1426 | { | |||||
| 1427 | 15 | 13 | my ($raw) = @_; | |||
| 1428 | 15 | 22 | $raw =~ s/[\r\n]//g; | |||
| 1429 | # Accept exactly 2 alpha chars, optionally followed by whitespace and a | |||||
| 1430 | # comment (e.g. "GB # United Kingdom"). Anything else (CRLF injection | |||||
| 1431 | # leftovers, embedded headers) returns undef so the caller can discard it | |||||
| 1432 | # rather than propagating a malformed string through the geo pipeline. | |||||
| 1433 | 15 | 65 | if($raw =~ /^([A-Za-z]{2})\s*(?:#.*)?$/) { | |||
| 1434 | 14 | 23 | return $1; | |||
| 1435 | } | |||||
| 1436 | 1 | 1 | return; | |||
| 1437 | } | |||||
| 1438 | ||||||
| 1439 | # ââ _handle_eu_country ââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1440 | # Purpose: Resolve the ambiguous 'eu' country code. RT-86809 shows that | |||||
| 1441 | # Baidu reports itself as EU when it is actually in CN. All | |||||
| 1442 | # other 'eu' addresses are logged as Unknown. | |||||
| 1443 | # Entry: $ip â validated, untainted IP string. | |||||
| 1444 | # Exit: Sets $self->{_country} to 'cn' or 'Unknown'. | |||||
| 1445 | # Side Effects: Loads Net::Subnet; writes info log entry. | |||||
| 1446 | sub _handle_eu_country | |||||
| 1447 | { | |||||
| 1448 | 4 | 23 | my ($self, $ip) = @_; | |||
| 1449 | ||||||
| 1450 | 4 | 391 | require Net::Subnet; | |||
| 1451 | 4 | 1688 | Net::Subnet->import(); | |||
| 1452 | ||||||
| 1453 | 4 | 7 | if(subnet_matcher($BAIDU_SUBNET)->($ip)) { | |||
| 1454 | 2 | 82 | $self->{_country} = 'cn'; | |||
| 1455 | } else { | |||||
| 1456 | 2 | 39 | $self->_info("$ip has country of eu"); | |||
| 1457 | 2 | 81 | $self->{_country} = 'Unknown'; | |||
| 1458 | } | |||||
| 1459 | } | |||||
| 1460 | ||||||
| 1461 | # ââ _load_geoip âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1462 | # Purpose: Probe for the Geo::IP database file and the Geo::IP module; | |||||
| 1463 | # set _have_geoip and initialise _geoip on success. | |||||
| 1464 | # Entry: _have_geoip must be GEO_UNKNOWN. | |||||
| 1465 | # Exit: _have_geoip set to GEO_PRESENT or GEO_ABSENT. | |||||
| 1466 | # Side Effects: Requires Geo::IP; opens GeoIP.dat. | |||||
| 1467 | sub _load_geoip | |||||
| 1468 | { | |||||
| 1469 | 3 | 21 | my $self = shift; | |||
| 1470 | ||||||
| 1471 | # Check for the database file before even trying to load the module | |||||
| 1472 | # (avoids noisy errors on Windows â CPANTESTERS report 54117bd0) | |||||
| 1473 | 3 | 42 | my $db_present = ( | |||
| 1474 | (($^O eq 'MSWin32') && (-r 'c:/GeoIP/GeoIP.dat')) | |||||
| 1475 | || (-r '/usr/local/share/GeoIP/GeoIP.dat') | |||||
| 1476 | || (-r '/usr/share/GeoIP/GeoIP.dat') | |||||
| 1477 | ); | |||||
| 1478 | ||||||
| 1479 | 3 | 3 | unless($db_present) { | |||
| 1480 | 3 | 6 | $self->{_have_geoip} = $GEO_ABSENT; | |||
| 1481 | 3 | 6 | return; | |||
| 1482 | } | |||||
| 1483 | ||||||
| 1484 | 0 0 | 0 0 | eval { require Geo::IP }; | |||
| 1485 | 0 | 0 | if($@) { | |||
| 1486 | 0 | 0 | $self->{_have_geoip} = $GEO_ABSENT; | |||
| 1487 | 0 | 0 | return; | |||
| 1488 | } | |||||
| 1489 | ||||||
| 1490 | # No ->import(): Geo::IP->open() and Geo::IP->new() are class methods; import unneeded. | |||||
| 1491 | 0 | 0 | $self->{_have_geoip} = $GEO_PRESENT; | |||
| 1492 | ||||||
| 1493 | # GEOIP_STANDARD = 0 (can't use the constant name directly) | |||||
| 1494 | 0 | 0 | if(-r '/usr/share/GeoIP/GeoIP.dat') { | |||
| 1495 | 0 | 0 | $self->{_geoip} = Geo::IP->open('/usr/share/GeoIP/GeoIP.dat', 0); | |||
| 1496 | } else { | |||||
| 1497 | 0 | 0 | $self->{_geoip} = Geo::IP->new(0); | |||
| 1498 | } | |||||
| 1499 | } | |||||
| 1500 | ||||||
| 1501 - 1534 | =head2 locale
HTTP doesn't have a way of transmitting a browser's localisation information
which would be useful for default currency, date formatting, etc.
This method attempts to detect the information, but it is a best guess
and is not 100% reliable. But it's better than nothing ;-)
Returns a L<Locale::Object::Country> object.
=head3 EXAMPLE
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $locale = $l->locale();
if (defined $locale) {
print $locale->name(); # e.g. "United States"
print $locale->currency_code(); # e.g. "USD"
}
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Locale::Object::Country | undef
=head3 PSEUDOCODE
1. Return cached _locale immediately if already computed
2. Parse HTTP_USER_AGENT parenthetical for xx-YY language tag
3. Try HTTP::BrowserDetect on the full User-Agent string
4. Fall back to country() IP lookup
5. Fall back to GEOIP_COUNTRY_CODE env var (ISO 3166-1 validated)
6. Return undef if all strategies fail
=cut | |||||
| 1535 | ||||||
| 1536 | sub locale { | |||||
| 1537 | 22 | 619 | my $self = shift; | |||
| 1538 | ||||||
| 1539 | 22 | 40 | return $self->{_locale} if $self->{_locale}; | |||
| 1540 | ||||||
| 1541 | # Validate and untaint HTTP_USER_AGENT before passing to any parser. | |||||
| 1542 | # The User-Agent header is attacker-controlled; apply the same discipline | |||||
| 1543 | # as HTTP_ACCEPT_LANGUAGE. Printable ASCII (0x20-0x7e), bounded length. | |||||
| 1544 | 20 | 16 | my $agent; | |||
| 1545 | 20 | 30 | if(defined(my $raw_agent = $ENV{'HTTP_USER_AGENT'})) { | |||
| 1546 | 10 | 21 | if($raw_agent =~ /^([\x20-\x7e]{1,$UA_MAX})$/a) { | |||
| 1547 | 8 | 68 | $agent = $1; # untainted | |||
| 1548 | } else { | |||||
| 1549 | 2 | 20 | $self->_warn({ warning => 'HTTP_USER_AGENT contains invalid characters or exceeds length limit; ignoring' }); | |||
| 1550 | } | |||||
| 1551 | } | |||||
| 1552 | ||||||
| 1553 | # First try: parse the language tag from the User-Agent parenthetical | |||||
| 1554 | 20 | 275 | if(defined($agent) && ($agent =~ /\((.+)\)/)) { | |||
| 1555 | 7 | 13 | foreach(split(/;/, $1)) { | |||
| 1556 | 16 | 13 | my $candidate = $_; | |||
| 1557 | 16 | 36 | $candidate =~ s/^\s+|\s+$//g; # trim both ends | |||
| 1558 | ||||||
| 1559 | 16 | 21 | if($candidate =~ /^[a-zA-Z]{2}-([a-zA-Z]{2})$/) { | |||
| 1560 | 2 | 5 | local $SIG{__WARN__} = undef; | |||
| 1561 | 2 | 3 | if(my $c = $self->_code2country($1)) { | |||
| 1562 | 2 | 3 | $self->{_locale} = $c; | |||
| 1563 | 2 | 7 | return $c; | |||
| 1564 | } | |||||
| 1565 | } | |||||
| 1566 | } | |||||
| 1567 | ||||||
| 1568 | # Second try: HTTP::BrowserDetect (works for more User-Agents) | |||||
| 1569 | 5 5 | 5 817 | if(eval { require HTTP::BrowserDetect }) { | |||
| 1570 | 5 | 15994 | HTTP::BrowserDetect->import(); | |||
| 1571 | 5 | 10 | my $browser = HTTP::BrowserDetect->new($agent); | |||
| 1572 | # Validate country() result before use â the return value comes from | |||||
| 1573 | # the third-party module and is not yet untainted or range-checked. | |||||
| 1574 | 5 | 442 | if($browser) { | |||
| 1575 | 5 | 11 | my $bc = $browser->country() // ''; | |||
| 1576 | 5 | 163 | if($bc =~ /^([A-Za-z]{2})$/a) { | |||
| 1577 | 1 | 2 | if(my $c = $self->_code2country($1)) { | |||
| 1578 | 1 | 2 | $self->{_locale} = $c; | |||
| 1579 | 1 | 1716 | return $c; | |||
| 1580 | } | |||||
| 1581 | } | |||||
| 1582 | } | |||||
| 1583 | } | |||||
| 1584 | } | |||||
| 1585 | ||||||
| 1586 | # Third try: IP address | |||||
| 1587 | 17 | 23 | my $country = $self->country(); | |||
| 1588 | 17 | 19 | if($country) { | |||
| 1589 | 8 | 12 | $country =~ s/[\r\n]//g; | |||
| 1590 | 8 | 6 | my $c; | |||
| 1591 | 8 | 6 | eval { | |||
| 1592 | 8 0 | 24 0 | local $SIG{__WARN__} = sub { die $_[0] }; | |||
| 1593 | 8 | 11 | $c = $self->_code2country($country); | |||
| 1594 | }; | |||||
| 1595 | 8 | 14 | unless($@) { | |||
| 1596 | 8 | 10 | if($c) { | |||
| 1597 | 8 | 11 | $self->{_locale} = $c; | |||
| 1598 | 8 | 10 | return $c; | |||
| 1599 | } | |||||
| 1600 | } | |||||
| 1601 | } | |||||
| 1602 | ||||||
| 1603 | # Fourth try: mod_geoip env var â apply the same ISO 3166-1 validation | |||||
| 1604 | # used in country() to guard against spoofed or malformed values | |||||
| 1605 | 9 | 14 | if(defined($ENV{'GEOIP_COUNTRY_CODE'})) { | |||
| 1606 | 2 | 4 | if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})$/a) { | |||
| 1607 | 0 | 0 | if(my $c = $self->_code2country(lc($1))) { | |||
| 1608 | 0 | 0 | $self->{_locale} = $c; | |||
| 1609 | 0 | 0 | return $c; | |||
| 1610 | } | |||||
| 1611 | } | |||||
| 1612 | } | |||||
| 1613 | 9 | 10 | return; | |||
| 1614 | } | |||||
| 1615 | ||||||
| 1616 - 1654 | =head2 time_zone
Returns the timezone of the web client.
If L<Geo::IP> is installed,
CGI::Lingua will make use of that, otherwise it will use L<ip-api.com>
=head3 API SPECIFICATION
Input: none beyond $self
Returns: Str (IANA timezone name) | undef
=head3 EXAMPLE
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $tz = $l->time_zone();
print $tz // 'unknown'; # e.g. "America/New_York"
=head3 MESSAGES
"Couldn't determine the timezone"
"LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com"
Returns undef rather than croaking; install either LWP variant to enable ip-api lookups.
=head3 PSEUDOCODE
1. Return cached _timezone immediately if already computed
2. If REMOTE_ADDR is set:
a. Untaint and validate the IP
b. Try Geo::IP->time_zone() (local DB)
c. Try LWP::Simple::WithCache + JSON::Parse against ip-api.com
d. Fall back to LWP::Simple + JSON::Parse against ip-api.com
e. Warn and return undef if neither LWP variant is installed
3. If REMOTE_ADDR is absent (local/CLI mode):
a. Read /etc/timezone if readable
b. Fall back to DateTime::TimeZone::Local->TimeZone()->name()
4. Warn "Couldn't determine the timezone" and return undef if all fail
=cut | |||||
| 1655 | ||||||
| 1656 | sub time_zone { | |||||
| 1657 | 12 | 668 | my $self = shift; | |||
| 1658 | ||||||
| 1659 | 12 | 18 | $self->_trace('Entered time_zone'); | |||
| 1660 | ||||||
| 1661 | 11 | 460 | if($self->{_timezone}) { | |||
| 1662 | 3 | 5 | $self->_trace('quick return: ', $self->{_timezone}); | |||
| 1663 | 3 | 91 | return $self->{_timezone}; | |||
| 1664 | } | |||||
| 1665 | ||||||
| 1666 | 8 | 12 | my $raw_ip = $ENV{'REMOTE_ADDR'}; | |||
| 1667 | ||||||
| 1668 | 8 | 9 | if(defined $raw_ip) { | |||
| 1669 | # Untaint before any external use â kept in sync with country()'s pattern, | |||||
| 1670 | # including the mixed-notation branch for ::ffff:a.b.c.d addresses. | |||||
| 1671 | 6 | 4 | my $ip; | |||
| 1672 | 6 | 21 | if($raw_ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/a) { | |||
| 1673 | 4 | 5 | $ip = $1; | |||
| 1674 | } elsif($raw_ip =~ /^([0-9a-fA-F:]{2,39}|[0-9a-fA-F:]{2,30}:(?:\d{1,3}\.){3}\d{1,3})$/a) { | |||||
| 1675 | 0 | 0 | $ip = $1; | |||
| 1676 | } else { | |||||
| 1677 | 2 | 5 | $self->_warn({ warning => "$raw_ip isn't a valid IP address" }); | |||
| 1678 | 2 | 4 | return; | |||
| 1679 | } | |||||
| 1680 | ||||||
| 1681 | 4 | 5 | if($self->{_have_geoip} == $GEO_UNKNOWN) { | |||
| 1682 | 1 | 5 | $self->_load_geoip(); | |||
| 1683 | } | |||||
| 1684 | 4 | 12 | if($self->{_have_geoip} == $GEO_PRESENT) { | |||
| 1685 | 1 1 | 2 2 | eval { $self->{_timezone} = $self->{_geoip}->time_zone($ip) }; | |||
| 1686 | } | |||||
| 1687 | ||||||
| 1688 | 4 | 10 | unless($self->{_timezone}) { | |||
| 1689 | 3 3 0 | 3 131 0 | if(eval { require LWP::Simple::WithCache; require JSON::Parse }) { | |||
| 1690 | 0 | 0 | $self->_debug("Look up $ip on ip-api.com"); | |||
| 1691 | ||||||
| 1692 | 0 | 0 | if(my $data = LWP::Simple::WithCache::get("http://ip-api.com/json/$ip")) { | |||
| 1693 | 0 0 | 0 0 | eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} }; | |||
| 1694 | 0 | 0 | $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@; | |||
| 1695 | } | |||||
| 1696 | 3 0 | 116 0 | } elsif(eval { require LWP::Simple; require JSON::Parse }) { | |||
| 1697 | 0 | 0 | $self->_debug("Look up $ip on ip-api.com"); | |||
| 1698 | ||||||
| 1699 | 0 | 0 | if(my $data = LWP::Simple::get("http://ip-api.com/json/$ip")) { | |||
| 1700 | 0 0 | 0 0 | eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} }; | |||
| 1701 | 0 | 0 | $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@; | |||
| 1702 | } | |||||
| 1703 | } else { | |||||
| 1704 | # Neither LWP variant is available â degrade gracefully rather than | |||||
| 1705 | # killing the entire request with a croak; caller can check for undef. | |||||
| 1706 | 3 | 5 | $self->_warn({ warning => 'LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com' }); | |||
| 1707 | } | |||||
| 1708 | } | |||||
| 1709 | } else { | |||||
| 1710 | # Local connection â read from /etc/timezone or DateTime::TimeZone | |||||
| 1711 | 2 | 33 | if(CORE::open(my $fin, '<', '/etc/timezone')) { | |||
| 1712 | 2 | 345 | my $tz = <$fin>; | |||
| 1713 | 2 | 3 | chomp $tz; | |||
| 1714 | 2 | 13 | $self->{_timezone} = $tz; | |||
| 1715 | } else { | |||||
| 1716 | 0 | 0 | $self->{_timezone} = DateTime::TimeZone::Local->TimeZone()->name(); | |||
| 1717 | } | |||||
| 1718 | } | |||||
| 1719 | ||||||
| 1720 | # Validate the timezone string against a permissive but bounded IANA pattern. | |||||
| 1721 | # Rejects XSS payloads (e.g. "Europe/London<script>...") from hostile JSON | |||||
| 1722 | # responses while accepting all real IANA zone names (e.g. "America/New_York", | |||||
| 1723 | # "Etc/GMT+8", "UTC"). | |||||
| 1724 | 6 | 407 | if(defined($self->{_timezone}) && | |||
| 1725 | $self->{_timezone} !~ /^[A-Za-z][A-Za-z0-9_+\-\/]{0,50}$/) { | |||||
| 1726 | 0 | 0 | $self->_warn({ warning => "Discarding malformed timezone '$self->{_timezone}'" }); | |||
| 1727 | 0 | 0 | delete $self->{_timezone}; | |||
| 1728 | } | |||||
| 1729 | ||||||
| 1730 | 6 | 9 | unless(defined($self->{_timezone})) { | |||
| 1731 | 3 | 4 | $self->_warn({ warning => "Couldn't determine the timezone" }); | |||
| 1732 | } | |||||
| 1733 | 6 | 225 | return $self->{_timezone}; | |||
| 1734 | } | |||||
| 1735 | ||||||
| 1736 - 1753 | =head2 is_rtl
Returns true (1) if the negotiated language is written right-to-left, false (0)
otherwise. Covers Arabic, Hebrew, Persian, Urdu, Yiddish, Dhivehi, Pashto,
Sindhi, Uyghur, and Kurdish.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ar';
my $l = CGI::Lingua->new(supported => ['ar', 'en']);
print $l->is_rtl(); # 1
=head3 API SPECIFICATION
Input: none beyond $self
Returns: 1 | 0
=cut | |||||
| 1754 | ||||||
| 1755 | sub is_rtl | |||||
| 1756 | { | |||||
| 1757 | 6 | 34 | my $self = shift; | |||
| 1758 | 6 | 7 | return $RTL_LANGS{$self->language_code_alpha2() // ''} ? 1 : 0; | |||
| 1759 | } | |||||
| 1760 | ||||||
| 1761 - 1777 | =head2 text_direction
Returns C<'rtl'> or C<'ltr'> for the negotiated language, suitable for direct
use as an HTML C<dir> attribute value.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'he';
my $l = CGI::Lingua->new(supported => ['he', 'en']);
print qq(<html dir="} . $l->text_direction() . qq(">); # dir="rtl"
=head3 API SPECIFICATION
Input: none beyond $self
Returns: 'rtl' | 'ltr'
=cut | |||||
| 1778 | ||||||
| 1779 | sub text_direction | |||||
| 1780 | { | |||||
| 1781 | 2 | 16 | my $self = shift; | |||
| 1782 | 2 | 2 | return $self->is_rtl() ? 'rtl' : 'ltr'; | |||
| 1783 | } | |||||
| 1784 | ||||||
| 1785 - 1811 | =head2 plural_category
Returns the CLDR plural category for the integer C<$n> in the negotiated
language. The returned string is one of C<'zero'>, C<'one'>, C<'two'>,
C<'few'>, C<'many'>, or C<'other'>.
Rules are embedded for ~70 languages including Arabic (6 forms), Slavic
languages (3-4 forms), Celtic languages (up to 6 forms), and Hebrew, Maltese,
Romanian, Latvian, Lithuanian, and Slovenian. Languages not in the table fall
back to the English rule (n == 1 => C<'one'>, else C<'other'>).
For fractional numbers or full CLDR v42+ accuracy, use C<Locale::CLDR>.
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ru';
my $l = CGI::Lingua->new(supported => ['ru']);
print $l->plural_category(1); # "one"
print $l->plural_category(3); # "few"
print $l->plural_category(11); # "many"
=head3 API SPECIFICATION
Input: $n - non-negative integer (fractional values are truncated)
Returns: Str - one of zero/one/two/few/many/other
=cut | |||||
| 1812 | ||||||
| 1813 | # CLDR plural category rules (https://unicode.org/cldr/charts/42/supplemental/language_plural_rules.html). | |||||
| 1814 | # Values are coderefs: ($n) â category string. Languages absent from this | |||||
| 1815 | # table fall back to the standard one/other rule inside plural_category(). | |||||
| 1816 | my %PLURAL_RULES = ( | |||||
| 1817 | ||||||
| 1818 | # ââ No plural distinction (always 'other') ââââââââââââââââââââââââââââ | |||||
| 1819 | (map { $_ => sub { 'other' } } | |||||
| 1820 | qw(az bm bo dz id ig ii in ja jbo jv jw kde kea km ko lkt lo ms | |||||
| 1821 | my nqo root sah ses sg th to vi wo yo zh)), | |||||
| 1822 | ||||||
| 1823 | # ââ Standard: n == 1 â 'one', else 'other' âââââââââââââââââââââââââââ | |||||
| 1824 | (map { $_ => sub { int($_[0]) == 1 ? 'one' : 'other' } } | |||||
| 1825 | qw(af an ast bg bn brx ca cgg da de el en eo es et eu fi | |||||
| 1826 | gl gsw gu ha haw hu ia it kcg kk kl lb lg mas ml mn mr | |||||
| 1827 | nb nd ne nl nn nyn om or pa pap rm rof rwk saq seh sn so | |||||
| 1828 | sq ss ssy st sv sw ta te teo tig tk tl tn ts ur wae xh xog)), | |||||
| 1829 | ||||||
| 1830 | # ââ French / Portuguese-BR: n ⤠1 â 'one' ââââââââââââââââââââââââââââ | |||||
| 1831 | (map { $_ => sub { int($_[0]) <= 1 ? 'one' : 'other' } } qw(fr pt_BR)), | |||||
| 1832 | ||||||
| 1833 | # ââ Arabic: zero/one/two/few/many/other âââââââââââââââââââââââââââââââ | |||||
| 1834 | ar => sub { | |||||
| 1835 | my $n = int($_[0]); | |||||
| 1836 | my $m100 = $n % 100; | |||||
| 1837 | return 'zero' if $n == 0; | |||||
| 1838 | return 'one' if $n == 1; | |||||
| 1839 | return 'two' if $n == 2; | |||||
| 1840 | return 'few' if $m100 >= 3 && $m100 <= 10; | |||||
| 1841 | return 'many' if $m100 >= 11 && $m100 <= 99; | |||||
| 1842 | return 'other'; | |||||
| 1843 | }, | |||||
| 1844 | ||||||
| 1845 | # ââ Hebrew: one/two/many/other ââââââââââââââââââââââââââââââââââââââââ | |||||
| 1846 | he => sub { | |||||
| 1847 | my $n = int($_[0]); | |||||
| 1848 | return 'one' if $n == 1; | |||||
| 1849 | return 'two' if $n == 2; | |||||
| 1850 | return 'many' if $n != 0 && $n % 10 == 0; | |||||
| 1851 | return 'other'; | |||||
| 1852 | }, | |||||
| 1853 | ||||||
| 1854 | # ââ Russian / Ukrainian / Belarusian: one/few/many ââââââââââââââââââââ | |||||
| 1855 | (map { $_ => sub { | |||||
| 1856 | my $n = int($_[0]); | |||||
| 1857 | my $m10 = $n % 10; | |||||
| 1858 | my $m100 = $n % 100; | |||||
| 1859 | return 'one' if $m10 == 1 && $m100 != 11; | |||||
| 1860 | return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20); | |||||
| 1861 | return 'many'; | |||||
| 1862 | } } qw(ru uk be)), | |||||
| 1863 | ||||||
| 1864 | # ââ Polish: one/few/many ââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1865 | pl => sub { | |||||
| 1866 | my $n = int($_[0]); | |||||
| 1867 | my $m10 = $n % 10; | |||||
| 1868 | my $m100 = $n % 100; | |||||
| 1869 | return 'one' if $n == 1; | |||||
| 1870 | return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20); | |||||
| 1871 | return 'many'; | |||||
| 1872 | }, | |||||
| 1873 | ||||||
| 1874 | # ââ Czech / Slovak: one/few/other ââââââââââââââââââââââââââââââââââââ | |||||
| 1875 | (map { $_ => sub { | |||||
| 1876 | my $n = int($_[0]); | |||||
| 1877 | return 'one' if $n == 1; | |||||
| 1878 | return 'few' if $n >= 2 && $n <= 4; | |||||
| 1879 | return 'other'; | |||||
| 1880 | } } qw(cs sk)), | |||||
| 1881 | ||||||
| 1882 | # ââ Romanian: one/few/other âââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1883 | ro => sub { | |||||
| 1884 | my $n = int($_[0]); | |||||
| 1885 | my $m100 = $n % 100; | |||||
| 1886 | return 'one' if $n == 1; | |||||
| 1887 | return 'few' if $n == 0 || ($m100 >= 1 && $m100 <= 19); | |||||
| 1888 | return 'other'; | |||||
| 1889 | }, | |||||
| 1890 | ||||||
| 1891 | # ââ Latvian: zero/one/other âââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1892 | lv => sub { | |||||
| 1893 | my $n = int($_[0]); | |||||
| 1894 | my $m10 = $n % 10; | |||||
| 1895 | my $m100 = $n % 100; | |||||
| 1896 | return 'zero' if $m10 == 0 || ($m100 >= 11 && $m100 <= 19); | |||||
| 1897 | return 'one' if $m10 == 1 && $m100 != 11; | |||||
| 1898 | return 'other'; | |||||
| 1899 | }, | |||||
| 1900 | ||||||
| 1901 | # ââ Lithuanian: one/few/other âââââââââââââââââââââââââââââââââââââââââ | |||||
| 1902 | lt => sub { | |||||
| 1903 | my $n = int($_[0]); | |||||
| 1904 | my $m10 = $n % 10; | |||||
| 1905 | my $m100 = $n % 100; | |||||
| 1906 | return 'one' if $m10 == 1 && ($m100 < 10 || $m100 >= 20); | |||||
| 1907 | return 'few' if $m10 >= 2 && ($m100 < 10 || $m100 >= 20); | |||||
| 1908 | return 'other'; | |||||
| 1909 | }, | |||||
| 1910 | ||||||
| 1911 | # ââ Slovenian: one/two/few/other ââââââââââââââââââââââââââââââââââââââ | |||||
| 1912 | sl => sub { | |||||
| 1913 | my $m100 = int($_[0]) % 100; | |||||
| 1914 | return 'one' if $m100 == 1; | |||||
| 1915 | return 'two' if $m100 == 2; | |||||
| 1916 | return 'few' if $m100 == 3 || $m100 == 4; | |||||
| 1917 | return 'other'; | |||||
| 1918 | }, | |||||
| 1919 | ||||||
| 1920 | # ââ Welsh: zero/one/two/few/many/other âââââââââââââââââââââââââââââââ | |||||
| 1921 | cy => sub { | |||||
| 1922 | my $n = int($_[0]); | |||||
| 1923 | return 'zero' if $n == 0; | |||||
| 1924 | return 'one' if $n == 1; | |||||
| 1925 | return 'two' if $n == 2; | |||||
| 1926 | return 'few' if $n == 3; | |||||
| 1927 | return 'many' if $n == 6; | |||||
| 1928 | return 'other'; | |||||
| 1929 | }, | |||||
| 1930 | ||||||
| 1931 | # ââ Irish: one/two/few/many/other ââââââââââââââââââââââââââââââââââââ | |||||
| 1932 | ga => sub { | |||||
| 1933 | my $n = int($_[0]); | |||||
| 1934 | return 'one' if $n == 1; | |||||
| 1935 | return 'two' if $n == 2; | |||||
| 1936 | return 'few' if $n >= 3 && $n <= 6; | |||||
| 1937 | return 'many' if $n >= 7 && $n <= 10; | |||||
| 1938 | return 'other'; | |||||
| 1939 | }, | |||||
| 1940 | ||||||
| 1941 | # ââ Maltese: one/two/few/many/other ââââââââââââââââââââââââââââââââââ | |||||
| 1942 | mt => sub { | |||||
| 1943 | my $n = int($_[0]); | |||||
| 1944 | my $m100 = $n % 100; | |||||
| 1945 | return 'one' if $n == 1; | |||||
| 1946 | return 'two' if $n == 2; | |||||
| 1947 | return 'few' if $n == 0 || ($m100 >= 3 && $m100 <= 10); | |||||
| 1948 | return 'many' if $m100 >= 11 && $m100 <= 19; | |||||
| 1949 | return 'other'; | |||||
| 1950 | }, | |||||
| 1951 | ); | |||||
| 1952 | ||||||
| 1953 | sub plural_category | |||||
| 1954 | { | |||||
| 1955 | 16 | 65 | my ($self, $n) = @_; | |||
| 1956 | 16 | 15 | croak('plural_category: $n must be defined') unless defined $n; | |||
| 1957 | 16 | 15 | my $code = $self->language_code_alpha2() // return 'other'; | |||
| 1958 | 15 0 | 16 0 | my $rule = $PLURAL_RULES{$code} // sub { int($_[0]) == 1 ? 'one' : 'other' }; | |||
| 1959 | 15 | 19 | return $rule->($n); | |||
| 1960 | } | |||||
| 1961 | ||||||
| 1962 - 2001 | =head2 translation_file
Returns the filesystem path to the best matching translation file for the
negotiated language in the given directory.
The lookup tries (in order):
=over 4
=item 1. C<$dir/$lang-$sublang.$ext> (e.g. C<en-gb.json>)
=item 2. C<$dir/$lang.$ext> (e.g. C<en.json>)
=back
Returns C<undef> if no matching file exists.
=head3 API SPECIFICATION
Input:
$dir - Str path to the directory containing translation files
$ext - Str file extension without leading dot (default: 'json')
Returns: Str (absolute or relative path) | undef
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb', 'en']);
my $path = $l->translation_file('/var/www/i18n');
# Returns '/var/www/i18n/en-gb.json' if it exists,
# then '/var/www/i18n/en.json', or undef.
# Custom extension:
my $path = $l->translation_file('/var/www/i18n', 'po');
=head3 MESSAGES
(none - returns undef silently when no file is found)
=cut | |||||
| 2002 | ||||||
| 2003 | sub translation_file | |||||
| 2004 | { | |||||
| 2005 | 6 | 344 | my ($self, $dir, $ext) = @_; | |||
| 2006 | 6 | 9 | return unless defined $dir; | |||
| 2007 | ||||||
| 2008 | # Reject traversal attempts in the directory argument. A real translation | |||||
| 2009 | # directory never needs '..', null bytes, or other shell metacharacters. | |||||
| 2010 | # The caller is responsible for not passing user-controlled data as $dir, | |||||
| 2011 | # but we guard here as a defence-in-depth measure. | |||||
| 2012 | 5 | 32 | if($dir =~ /\.\./ || $dir =~ /\x00/) { | |||
| 2013 | 0 | 0 | $self->_warn({ warning => "translation_file: unsafe directory '$dir' rejected" }); | |||
| 2014 | 0 | 0 | return; | |||
| 2015 | } | |||||
| 2016 | ||||||
| 2017 | 5 | 8 | $ext //= 'json'; | |||
| 2018 | 5 | 6 | $ext =~ s/^\.//; # accept '.json' or 'json' | |||
| 2019 | ||||||
| 2020 | # Reject extensions containing path-traversal sequences or shell metacharacters. | |||||
| 2021 | # Valid extensions are word characters and hyphens only (e.g. 'json', 'po', 'yml'). | |||||
| 2022 | 5 | 16 | unless($ext =~ /^[A-Za-z0-9\-]+$/) { | |||
| 2023 | 1 | 3 | $self->_warn({ warning => "translation_file: unsafe extension '$ext' rejected" }); | |||
| 2024 | 1 | 124 | return; | |||
| 2025 | } | |||||
| 2026 | ||||||
| 2027 | 4 | 4 | my @candidates; | |||
| 2028 | 4 | 7 | if(my $sub = $self->sublanguage_code_alpha2()) { | |||
| 2029 | 0 | 0 | push @candidates, $self->language_code_alpha2() . '-' . $sub; | |||
| 2030 | } | |||||
| 2031 | 4 | 4 | push @candidates, $self->language_code_alpha2() | |||
| 2032 | if defined $self->language_code_alpha2(); | |||||
| 2033 | ||||||
| 2034 | 4 | 4 | for my $code (@candidates) { | |||
| 2035 | 4 | 4 | my $path = "$dir/$code.$ext"; | |||
| 2036 | 4 | 30 | return $path if -e $path; | |||
| 2037 | } | |||||
| 2038 | 1 | 2 | return; | |||
| 2039 | } | |||||
| 2040 | ||||||
| 2041 | # ââ _code2language ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2042 | # Purpose: Translate a 2-char language code to its English name, with | |||||
| 2043 | # optional CHI caching. | |||||
| 2044 | # Entry: $code â 2-char ISO 639-1 code; must be defined and non-empty. | |||||
| 2045 | # Exit: Human-readable language name string, or undef. | |||||
| 2046 | # Side Effects: Reads/writes cache. | |||||
| 2047 | sub _code2language | |||||
| 2048 | { | |||||
| 2049 | 152 | 660 | my ($self, $code) = @_; | |||
| 2050 | ||||||
| 2051 | 152 | 157 | return unless $code; | |||
| 2052 | 150 | 141 | if(defined($self->{_country})) { | |||
| 2053 | 3 | 6 | $self->_debug("_code2language $code, country ", $self->{_country}); | |||
| 2054 | } else { | |||||
| 2055 | 147 | 181 | $self->_debug("_code2language $code"); | |||
| 2056 | } | |||||
| 2057 | ||||||
| 2058 | 150 | 4500 | unless($self->{_cache}) { | |||
| 2059 | 128 | 165 | return Locale::Language::code2language($code); | |||
| 2060 | } | |||||
| 2061 | ||||||
| 2062 | 22 | 40 | if(my $from_cache = $self->{_cache}->get($CACHE_NS . "code2language:$code")) { | |||
| 2063 | 7 | 556 | $self->_trace("_code2language found in cache $from_cache"); | |||
| 2064 | 7 | 145 | return $from_cache; | |||
| 2065 | } | |||||
| 2066 | ||||||
| 2067 | # Compute, cache, then return the value separately â | |||||
| 2068 | # CHI->set() is not guaranteed to return the stored value across all drivers | |||||
| 2069 | 15 | 595 | $self->_trace('_code2language not in cache, storing'); | |||
| 2070 | 15 | 485 | my $name = Locale::Language::code2language($code); | |||
| 2071 | 15 | 363272 | if(defined $name) { | |||
| 2072 | 14 | 38 | $self->{_cache}->set($CACHE_NS . "code2language:$code", $name, $CACHE_TTL_LONG); | |||
| 2073 | } | |||||
| 2074 | 15 | 1988 | return $name; | |||
| 2075 | } | |||||
| 2076 | ||||||
| 2077 | # ââ _code2country âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2078 | # Purpose: Translate a 2-char country code to a Locale::Object::Country | |||||
| 2079 | # object, suppressing the expected "No result found" warning. | |||||
| 2080 | # Entry: $code â 2-char ISO 3166-1 alpha-2 code (any case). | |||||
| 2081 | # Exit: Locale::Object::Country object, or undef. | |||||
| 2082 | # Side Effects: None beyond the Locale::Object::Country look-up. | |||||
| 2083 | sub _code2country | |||||
| 2084 | { | |||||
| 2085 | 90 | 444 | my ($self, $code) = @_; | |||
| 2086 | ||||||
| 2087 | 90 | 75 | return unless $code; | |||
| 2088 | 88 | 104 | if($self->{_country}) { | |||
| 2089 | 13 | 17 | $self->_trace(">_code2country $code, country ", $self->{_country}); | |||
| 2090 | } else { | |||||
| 2091 | 75 | 104 | $self->_trace(">_code2country $code"); | |||
| 2092 | } | |||||
| 2093 | ||||||
| 2094 | 88 | 2591 | my $rc; | |||
| 2095 | { | |||||
| 2096 | # Scope the signal handler tightly â only suppress the one known-harmless warning | |||||
| 2097 | 88 | 55 | local $SIG{__WARN__} = sub { | |||
| 2098 | 14 | 77349 | warn $_[0] unless $_[0] =~ /No result found in country table/; | |||
| 2099 | 88 | 224 | }; | |||
| 2100 | 88 | 264 | $rc = Locale::Object::Country->new(code_alpha2 => $code); | |||
| 2101 | } | |||||
| 2102 | 88 | 2481340 | $self->_trace('<_code2country ', $code || 'undef'); | |||
| 2103 | 88 | 3209 | return $rc; | |||
| 2104 | } | |||||
| 2105 | ||||||
| 2106 | # ââ _code2countryname âââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2107 | # Purpose: Translate a 2-char country code to its English name string, | |||||
| 2108 | # with optional CHI caching. | |||||
| 2109 | # Entry: $code â 2-char ISO 3166-1 alpha-2 code. | |||||
| 2110 | # Exit: Country name string, or undef. | |||||
| 2111 | # Side Effects: Reads/writes cache. | |||||
| 2112 | sub _code2countryname | |||||
| 2113 | { | |||||
| 2114 | 51 | 308 | my ($self, $code) = @_; | |||
| 2115 | ||||||
| 2116 | 51 | 61 | return unless $code; | |||
| 2117 | 49 | 69 | $self->_trace(">_code2countryname $code"); | |||
| 2118 | ||||||
| 2119 | 49 | 1621 | unless($self->{_cache}) { | |||
| 2120 | 42 | 54 | my $country = $self->_code2country($code); | |||
| 2121 | 42 | 85 | return defined($country) ? $country->name : undef; | |||
| 2122 | } | |||||
| 2123 | ||||||
| 2124 | 7 | 13 | if(my $from_cache = $self->{_cache}->get($CACHE_NS . "code2countryname:$code")) { | |||
| 2125 | 1 | 72 | $self->_trace("_code2countryname found in cache $from_cache"); | |||
| 2126 | 1 | 34 | return $from_cache; | |||
| 2127 | } | |||||
| 2128 | ||||||
| 2129 | 6 | 238 | if(my $country = $self->_code2country($code)) { | |||
| 2130 | 5 | 10 | $self->_debug('_code2countryname not in cache, storing'); | |||
| 2131 | 5 | 137 | my $name = $country->name(); | |||
| 2132 | 5 | 20 | $self->_trace('<_code2countryname ', $name); | |||
| 2133 | # Store then return explicitly â don't rely on set() return value | |||||
| 2134 | 5 | 127 | $self->{_cache}->set($CACHE_NS . "code2countryname:$code", $name, $CACHE_TTL_LONG); | |||
| 2135 | 5 | 713 | return $name; | |||
| 2136 | } | |||||
| 2137 | 1 | 2 | $self->_trace('<_code2countryname undef'); | |||
| 2138 | 1 | 31 | return; | |||
| 2139 | } | |||||
| 2140 | ||||||
| 2141 | # ââ _log ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2142 | # Purpose: Append a message to $self->{messages} and forward to the | |||||
| 2143 | # optional logger object. | |||||
| 2144 | # Entry: $level â log level string (debug/info/notice/warn/trace/error); | |||||
| 2145 | # @messages â one or more strings to concatenate. | |||||
| 2146 | # Exit: void | |||||
| 2147 | # Side Effects: Mutates $self->{messages}; calls logger method if set. | |||||
| 2148 | sub _log | |||||
| 2149 | { | |||||
| 2150 | 2351 | 3109 | my ($self, $level, @messages) = @_; | |||
| 2151 | ||||||
| 2152 | 2351 | 2972 | return unless ref($self) && scalar(@messages); | |||
| 2153 | ||||||
| 2154 | 2349 | 2799 | my $text = join('', grep defined, @messages); | |||
| 2155 | 2349 | 1737 | return unless length($text); | |||
| 2156 | 2348 2348 | 1206 2962 | push @{$self->{'messages'}}, { level => $level, message => $text }; | |||
| 2157 | ||||||
| 2158 | 2348 | 2190 | if(my $logger = $self->{'logger'}) { | |||
| 2159 | 2346 | 2615 | $logger->$level($text); | |||
| 2160 | } | |||||
| 2161 | } | |||||
| 2162 | ||||||
| 2163 | 1192 1192 | 695 987 | sub _debug { my $self = shift; $self->_log('debug', @_) } | |||
| 2164 | 4 4 | 1037 6 | sub _info { my $self = shift; $self->_log('info', @_) } | |||
| 2165 | 4 4 | 996 11 | sub _notice { my $self = shift; $self->_log('notice', @_) } | |||
| 2166 | 1142 1142 | 1750 1066 | sub _trace { my $self = shift; $self->_log('trace', @_) } | |||
| 2167 | ||||||
| 2168 | # ââ _warn âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2169 | # Purpose: Emit a warning through the logger (if set) or via Carp::carp. | |||||
| 2170 | # Entry: A single hashref argument: { warning => 'message text' }. | |||||
| 2171 | # All callers MUST use this structured form â plain-string calls | |||||
| 2172 | # silently lose the message when no logger is configured. | |||||
| 2173 | # Exit: void | |||||
| 2174 | # Side Effects: Calls logger->warn() or carp(). | |||||
| 2175 | sub _warn | |||||
| 2176 | { | |||||
| 2177 | 51 | 117 | my $self = shift; | |||
| 2178 | ||||||
| 2179 | # Parse once; both branches need the same $msg. | |||||
| 2180 | 51 | 64 | my $params = Params::Get::get_params('warning', @_); | |||
| 2181 | 51 | 764 | my $msg = (ref($params) ? $params->{'warning'} : undef) // join('', grep defined, @_); | |||
| 2182 | ||||||
| 2183 | 51 | 52 | if(defined($self->{'logger'})) { | |||
| 2184 | 49 | 64 | $self->{'logger'}->warn($msg); | |||
| 2185 | } else { | |||||
| 2186 | 2 | 3 | $self->_log('warn', $msg); | |||
| 2187 | 2 | 3 | carp($msg); | |||
| 2188 | } | |||||
| 2189 | } | |||||
| 2190 | ||||||
| 2191 - 2431 | =head1 LIMITATIONS
=over 4
=item * B<is_rtl() covers primary-script RTL languages only>
C<is_rtl()> returns true for the 10 ISO 639-1 codes whose overwhelmingly
dominant script is right-to-left. Languages with script variants (e.g.
Azerbaijani C<az>, which uses Latin in modern Azerbaijan but Arabic in Iran)
are treated as LTR. If you serve content in multiple scripts of the same
language, inspect the sublanguage or Accept-Language header directly.
=item * B<plural_category() uses embedded CLDR rules, not Locale::CLDR>
The embedded rules cover ~70 languages and truncate fractional C<$n> to an
integer. For full CLDR v42 accuracy (including fractional forms and
languages not in the table) install and use C<Locale::CLDR> directly.
=item * B<Logger must be a blessed object>
The C<logger> parameter is documented as accepting a code ref, array ref, or
filename, but the current implementation calls C<< $logger->$level() >> and will
die on non-blessed values. Wrap alternative logger types in a
C<Log::Abstraction> instance before passing them to C<new()>.
=item * B<es-419 sublanguage returns undef>
Three-part regional codes such as C<es-419> (Latin American Spanish) do not
resolve to a C<sublanguage()> value because ISO 3166-1 does not define '419'.
This is a known limitation of the Locale::Object layer.
=item * B<Whois lookups are slow and unreliable>
Without C<IP::Country>, C<Geo::IP>, or C<Geo::IPfree> installed, C<country()>
falls back to Whois queries against live RIPE/ARIN/IANA servers. These can
time out under load. Install at least one local geo-database module and enable
the CHI cache to avoid this.
=item * B<Private methods are accessible from outside the package>
The C<_*> methods use the naming convention for privacy but Perl does not enforce
it. C<Sub::Private> or C<Sub::Protected> should be added once all white-box
tests (C<t/function.t>, C<t/extended_tests.t>) are updated to use the public API
exclusively.
=item * B<IPv4-mapped IPv6 addresses are normalised to IPv4>
C<REMOTE_ADDR> values in the form C<::ffff:a.b.c.d> (RFC 4291 section 2.5.5)
are silently rewritten to the embedded C<a.b.c.d> IPv4 address before any
geo-lookup. This is correct for country detection purposes but means the raw
address string is not preserved in cache keys or log messages.
=item * B<EU country code is irresolvable (with one exception)>
IP addresses that Whois reports as country C<EU> are mapped to C<'Unknown'>
unless they fall within Baidu's known subnet (RT-86809). There is no ISO
3166-1 country code for the European Union.
=item * B<country() does not cache undef results>
When C<country()> cannot determine a country (private IPs, loopback,
unresolvable addresses), it returns C<undef> without storing the result. A
second call on the same object repeats the full validation pipeline. This is
intentional: C<country()> reads C<REMOTE_ADDR> at call time rather than at
construction time, so caching C<undef> would return a wrong answer if
C<REMOTE_ADDR> changes between calls. In practice this is rarely a problem
because C<country()> is called once per request and CGI applications typically
create a fresh object per request.
=back
=head1 AUTHOR
Nigel Horne, C<< <njh at nigelhorne.com> >>
=head1 BUGS
Please report any bugs or feature requests to the author.
If C<HTTP_ACCEPT_LANGUAGE> contains a sub-tag with a 3-digit UN M.49 region
code (e.g. C<es-419> for Latin American Spanish), C<sublanguage()> returns
C<undef> because ISO 3166-1 does not define numeric codes.
Please report any bugs or feature requests to C<bug-cgi-lingua at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=CGI-Lingua>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.
Uses L<I18N::AcceptLanguage> to find the highest priority accepted language.
This means that if you support languages at a lower priority, it may be missed.
=head1 SEE ALSO
=over 4
=item * L<Configure an Object at Runtime|Object::Configure>
=item * L<Test Dashboard|https://nigelhorne.github.io/CGI-Lingua/coverage/>
=item * VWF - Versatile Web Framework L<https://github.com/nigelhorne/vwf>
=item * L<HTTP::BrowserDetect>
=item * L<I18N::AcceptLanguage>
=item * L<Locale::Country>
=back
=head1 SUPPORT
This module is provided as-is without any warranty.
You can find documentation for this module with the perldoc command.
perldoc CGI::Lingua
You can also look for information at:
=over 4
=item * MetaCPAN
L<https://metacpan.org/release/CGI-Lingua>
=item * RT: CPAN's request tracker
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=CGI-Lingua>
=item * CPANTS
L<http://cpants.cpanauthors.org/dist/CGI-Lingua>
=item * CPAN Testers' Matrix
L<http://matrix.cpantesters.org/?dist=CGI-Lingua>
=item * CPAN Testers Dependencies
L<http://deps.cpantesters.org/?module=CGI::Lingua>
=back
=encoding utf-8
=head1 FORMAL SPECIFICATION
=head2 new
new : Class à Params â CGI::Lingua
â p : Params ⢠p.supported â â
â¹ result.language â (p.supported ⪠{'Unknown'})
=head2 language
language : CGI::Lingua â Str
result â {name(l) | l â supported} ⪠{'Unknown'}
=head2 sublanguage
sublanguage : CGI::Lingua -> Str | undef
result = country_name(sublanguage_code_alpha2(self))
when sublanguage_code_alpha2(self) is defined,
undef otherwise
=head2 language_code_alpha2
language_code_alpha2 : CGI::Lingua -> Str(2) | undef
result = base_code(matched_supported_entry)
when a supported language was matched, undef otherwise
=head2 sublanguage_code_alpha2
sublanguage_code_alpha2 : CGI::Lingua -> Str(2) | undef
result = variety_code(matched_supported_entry) | undef
=head2 requested_language
requested_language : CGI::Lingua -> Str
result = name(base) + " (" + name(variety) + ")"
when variety is known,
= name(base) when no variety,
= 'Unknown' when no language detected
=head2 country
country : CGI::Lingua -> Str(2,lowercase) | undef
-- 'Unknown' returned only in the EU/Baidu special case
result = lc(code) where code satisfies ISO 3166-1 alpha-2
| undef when IP is private, loopback, or unresolvable
=head2 locale
locale : CGI::Lingua -> Locale::Object::Country | undef
-- Best-guess detection; not guaranteed accurate.
result = first defined value from:
1. UA parenthetical language tag
2. HTTP::BrowserDetect country
3. country() IP lookup
4. GEOIP_COUNTRY_CODE env var
=head2 time_zone
time_zone : CGI::Lingua -> Str | undef
result is an IANA timezone name (e.g. 'Europe/London') or undef
=head2 is_rtl
is_rtl : CGI::Lingua â Bool
is_rtl(s) â language_code_alpha2(s) â RTL_LANGS
=head2 text_direction
text_direction : CGI::Lingua â {'rtl', 'ltr'}
text_direction(s) â is_rtl(s) ? 'rtl' : 'ltr'
=head2 plural_category
plural_category : CGI::Lingua x N -> PluralCategory
plural_category(s, n) = PLURAL_RULES[language_code_alpha2(s)](trunc(n))
-- Falls back to English rule (n=1 -> 'one'; else 'other')
-- when language_code_alpha2(s) is undef or not in the rules table.
=head2 translation_file
translation_file : CGI::Lingua à Path à Ext â Path | undef
translation_file(s, d, e) â
first p â candidates(s) ⢠â file d/p.e
where candidates(s) = [lang(s)-sublang(s), lang(s)] \ {undef}
=head1 ACKNOWLEDGEMENTS
=head1 LICENSE AND COPYRIGHT
Copyright 2010-2026 Nigel Horne.
Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.
=cut | |||||
| 2432 | ||||||
| 2433 | 1; # End of CGI::Lingua | |||||