| File: | blib/lib/CGI/Lingua.pm |
| Coverage: | 90.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package CGI::Lingua; | |||||
| 2 | ||||||
| 3 | 77 77 77 | 2157220 61 1811 | use warnings; | |||
| 4 | 77 77 77 | 121 43 763 | use strict; | |||
| 5 | 77 77 77 | 3856 489891 141 | use autodie qw(:all); | |||
| 6 | ||||||
| 7 | 77 77 77 | 611951 77 905 | use Carp (); # fully qualified calls: nothing imported into this package | |||
| 8 | 77 77 77 | 8795 4667150 1574 | use Object::Configure 0.23; | |||
| 9 | 77 77 77 | 236 364 1157 | use Params::Get 0.15; # 0.15 fast-path: unblessed hashref returned directly | |||
| 10 | 77 77 77 | 127 66 1129 | use Readonly; | |||
| 11 | 77 77 77 | 126 63 393 | use Scalar::Util (); # fully qualified, so $obj->blessed() is not a method | |||
| 12 | 77 77 77 | 9864 170832 835 | use JSON::PP (); | |||
| 13 | 77 | 221 | use Class::Autouse qw{ | |||
| 14 | Locale::Language | |||||
| 15 | Locale::Object::Country | |||||
| 16 | Locale::Object::DB | |||||
| 17 | I18N::AcceptLanguage | |||||
| 18 | 77 77 | 7773 246152 | }; | |||
| 19 | ||||||
| 20 | our $VERSION = '0.87'; | |||||
| 21 | ||||||
| 22 | # Post-release roadmap (from the 0.87 gap analysis). | |||||
| 23 | # | |||||
| 24 | # Upstream services | |||||
| 25 | # TODO: geoplugin.net has no free tier any more, so that look-up is dead code | |||||
| 26 | # for nearly everyone. Make the web providers configurable | |||||
| 27 | # (geo_providers => [...]) with a built-in client for a free HTTPS | |||||
| 28 | # service, or drop geoplugin. | |||||
| 29 | # TODO: ip-api.com is plain HTTP and limited to 45 requests a minute; answers | |||||
| 30 | # are validated but can be read and altered in transit. Allow an API key | |||||
| 31 | # / HTTPS provider and add rate limiting or back-off. | |||||
| 32 | # TODO: Support MaxMind GeoLite2 (GeoIP2::Database::Reader or | |||||
| 33 | # IP::Geolocation::MMDB); GeoIP.dat (frozen 2019) and Geo::IPfree (known | |||||
| 34 | # wrong entries) are the only local databases today. | |||||
| 35 | # TODO: Add a timeout option: a slow upstream holds the request for LWP's | |||||
| 36 | # default of 180 seconds. | |||||
| 37 | # | |||||
| 38 | # Features | |||||
| 39 | # TODO: native_name() ("Francais" for French, in that language's script), | |||||
| 40 | # plus bcp47() / lang_attribute() for the HTML lang attribute. | |||||
| 41 | # TODO: Number, currency and date formatting helpers from the negotiated | |||||
| 42 | # locale (CLDR via Locale::CLDR, optional). | |||||
| 43 | # TODO: Script detection (zh-Hant / zh-Hans, sr-Latn) and RTL by script | |||||
| 44 | # rather than by language. | |||||
| 45 | # TODO: Proper 3-letter and UN M.49 regions (es-419, en-029); these are the | |||||
| 46 | # remaining TODO tests (t/es_419.t, t/en_029.t). | |||||
| 47 | # TODO: A Content-Language / Vary: Accept-Language header helper, and RFC | |||||
| 48 | # 4647 "lookup" fallback chains. | |||||
| 49 | # TODO: PSGI / Plack: middleware or a from_env(\%env) constructor that does | |||||
| 50 | # not depend on %ENV, removing the "country() reads REMOTE_ADDR when | |||||
| 51 | # called" pitfall. | |||||
| 52 | # | |||||
| 53 | # Technical debt | |||||
| 54 | # TODO: Split this file into ::Negotiate, ::Geo (one class per provider) and | |||||
| 55 | # ::Cache; most of the mocking pitfalls in the tests come from the | |||||
| 56 | # coupling. | |||||
| 57 | # TODO: Replace the three geo sentinels and the two package sentinels | |||||
| 58 | # ($_locale_object_db_ok, $_have_dvip) with one capability probe, cached | |||||
| 59 | # per process. | |||||
| 60 | # TODO: Enforce privacy with Sub::Private once t/function.t and | |||||
| 61 | # t/extended_tests.t stop calling _-methods directly. | |||||
| 62 | # TODO: Structured log events (a code plus fields) instead of English | |||||
| 63 | # strings, so applications can translate and filter them. | |||||
| 64 | # TODO: t/integration.t takes about 15 seconds because of its ~45 child | |||||
| 65 | # processes; run them in parallel or reuse one child per combination. | |||||
| 66 | ||||||
| 67 | # Gathering magic strings here makes behavioural changes one-edit operations. | |||||
| 68 | ||||||
| 69 | Readonly my $CACHE_TTL_LONG => '1 month'; | |||||
| 70 | Readonly my $CACHE_TTL_SHORT => '1 hour'; | |||||
| 71 | Readonly my $CACHE_NS => 'CGI::Lingua:'; # namespace prefix for every key | |||||
| 72 | Readonly my $BROKEN_GEOIPFREE => '45.128.139.41'; # https://github.com/bricas/geo-ipfree/issues/10 | |||||
| 73 | Readonly my $BAIDU_SUBNET => '185.10.104.0/22';# RT-86809: Baidu misreports as EU | |||||
| 74 | Readonly my $DEPRECATED_EN_UK => 'en-uk'; # some browsers still send this | |||||
| 75 | Readonly my $CANONICAL_EN_GB => 'en-gb'; | |||||
| 76 | Readonly my $ACCEPT_LANG_MAX => 256; # max bytes we accept from the header | |||||
| 77 | Readonly my $UA_MAX => 512; # max bytes we accept from HTTP_USER_AGENT | |||||
| 78 | Readonly my $GEO_UNKNOWN => -1; # geo-module sentinel: not yet probed | |||||
| 79 | Readonly my $GEO_ABSENT => 0; # geo-module sentinel: unavailable | |||||
| 80 | Readonly my $GEO_PRESENT => 1; # geo-module sentinel: loaded OK | |||||
| 81 | ||||||
| 82 | # Shapes of the values CGI::Lingua keeps in the cache. Anything read back | |||||
| 83 | # that does not match is treated as poisoned and discarded. Language and | |||||
| 84 | # country names from Locale::Language, Locale::Codes and Locale::Object use | |||||
| 85 | # only these characters (e.g. "Cote D'Ivoire", "Modern Greek (1453-)", | |||||
| 86 | # "Falkland Islands (The) [Malvinas]"); none of them use < > " & ; / =. | |||||
| 87 | Readonly my $NAME_RE => qr/^[A-Za-z][A-Za-z0-9 ,.:'()\[\]\-]{0,99}\z/a; # ":" for "English (Unknown: zz)" | |||||
| 88 | Readonly my $NAME_CODE_RE => qr/^[A-Za-z][A-Za-z0-9 ,.'()\[\]\-]{0,99}=[a-z]{2,3}\z/a; | |||||
| 89 | Readonly my $LANG_CODE_RE => qr/^[a-z]{2,3}\z/a; | |||||
| 90 | Readonly my $COUNTRY_RE => qr/^(?:[a-z]{2}|Unknown)\z/a; | |||||
| 91 | ||||||
| 92 | # A language list from HTTP_ACCEPT_LANGUAGE or a lang= parameter: the RFC 7231 | |||||
| 93 | # characters plus "*", spaces and tabs (never CR or LF), at most | |||||
| 94 | # $ACCEPT_LANG_MAX bytes. Captures the untainted value. | |||||
| 95 | Readonly my $ACCEPT_LANG_RE => qr/^([A-Za-z0-9\-,;=.* \t]{1,$ACCEPT_LANG_MAX})\z/a; | |||||
| 96 | ||||||
| 97 | # A supported-list entry: a language tag such as "en", "en-gb", "es-419" or | |||||
| 98 | # "zh-Hant" (an underscore is accepted for "en_gb"). "english" is not one. | |||||
| 99 | Readonly my $SUPPORTED_RE => qr/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*\z/a; | |||||
| 100 | ||||||
| 101 | # An IP address as accepted from REMOTE_ADDR, before Data::Validate::IP checks | |||||
| 102 | # it properly: dotted quad, or IPv6 including ::ffff:a.b.c.d. Captures it. | |||||
| 103 | Readonly my $IP_SHAPE_RE => qr/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|[0-9a-fA-F:]{2,39}|[0-9a-fA-F:]{2,30}:(?:\d{1,3}\.){3}\d{1,3})\z/a; | |||||
| 104 | ||||||
| 105 | # Fields that DESTROY saves and new() restores, with the shape each must have | |||||
| 106 | # time_zone(): IANA zone names (e.g. "America/New_York", "Etc/GMT+8", "UTC") | |||||
| 107 | Readonly my $ZONE_RE => qr/^[A-Za-z][A-Za-z0-9_+\-\/]{0,50}\z/a; | |||||
| 108 | Readonly my $ZONE_FILE_MAX => 256; # bytes read from $ZONE_FILE; a zone name is far shorter | |||||
| 109 | ||||||
| 110 | # File holding the system time zone, read by time_zone() when there is no | |||||
| 111 | # REMOTE_ADDR. A package variable (not Readonly) so tests can point it at a | |||||
| 112 | # hostile file with "local $CGI::Lingua::ZONE_FILE = ...". | |||||
| 113 | our $ZONE_FILE = '/etc/timezone'; | |||||
| 114 | ||||||
| 115 | # Where _load_geoip() looks for the legacy MaxMind GeoIP.dat used by Geo::IP; | |||||
| 116 | # the first readable regular file wins. A package variable so a test or a | |||||
| 117 | # site can point it elsewhere ("local @CGI::Lingua::GEOIP_DAT = ..."). | |||||
| 118 | our @GEOIP_DAT = ( | |||||
| 119 | (($^O eq 'MSWin32') ? ('c:/GeoIP/GeoIP.dat') : ()), | |||||
| 120 | '/usr/share/GeoIP/GeoIP.dat', | |||||
| 121 | '/usr/local/share/GeoIP/GeoIP.dat', | |||||
| 122 | ); | |||||
| 123 | ||||||
| 124 | Readonly my %RESTORABLE => ( | |||||
| 125 | _slanguage => $NAME_RE, | |||||
| 126 | _rlanguage => $NAME_RE, | |||||
| 127 | _sublanguage => $NAME_RE, | |||||
| 128 | _slanguage_code_alpha2 => $LANG_CODE_RE, | |||||
| 129 | _sublanguage_code_alpha2 => $LANG_CODE_RE, | |||||
| 130 | _country => $COUNTRY_RE, | |||||
| 131 | ); | |||||
| 132 | ||||||
| 133 | # Package-level sentinel for Locale::Object's SQLite database. undef = not yet | |||||
| 134 | # probed; 0 = database absent (Windows installers often omit it); 1 = available. | |||||
| 135 | # Package-level (not per-object) because the database either exists on the | |||||
| 136 | # filesystem or it doesn't â there is no per-request variability. | |||||
| 137 | my $_locale_object_db_ok; | |||||
| 138 | ||||||
| 139 | # Package-level sentinel for Data::Validate::IP / NetAddr::IP availability. | |||||
| 140 | # NetAddr::IP::UtilPP fails to build on Windows (mask4to6 bad-argument error), | |||||
| 141 | # which cascades to Data::Validate::IP. undef = not yet probed; 0 = broken; | |||||
| 142 | # 1 = available. On first country() call we try to load the module and, if it | |||||
| 143 | # fails, install pure-Perl aliases for the four functions we use. | |||||
| 144 | my $_have_dvip; | |||||
| 145 | ||||||
| 146 | # Short-name overrides used when Locale::Object's database is absent and we | |||||
| 147 | # fall back to Locale::Codes::Country. Locale::Codes carries full ISO official | |||||
| 148 | # names (e.g. "United Kingdom of Great Britain and Northern Ireland") while | |||||
| 149 | # Locale::Object returns the common short form ("United Kingdom"). Only | |||||
| 150 | # entries that differ are listed; everything else comes from Locale::Codes. | |||||
| 151 | my %COUNTRY_SHORT_NAMES = ( | |||||
| 152 | bo => 'Bolivia', | |||||
| 153 | cd => 'Democratic Republic of the Congo', | |||||
| 154 | fk => 'Falkland Islands', | |||||
| 155 | fm => 'Micronesia', | |||||
| 156 | gb => 'United Kingdom', | |||||
| 157 | ir => 'Iran', | |||||
| 158 | kp => 'North Korea', | |||||
| 159 | kr => 'South Korea', | |||||
| 160 | md => 'Moldova', | |||||
| 161 | nl => 'Netherlands', | |||||
| 162 | ps => 'Palestine', | |||||
| 163 | tw => 'Taiwan', | |||||
| 164 | tz => 'Tanzania', | |||||
| 165 | us => 'United States', | |||||
| 166 | ve => 'Venezuela', | |||||
| 167 | ); | |||||
| 168 | ||||||
| 169 | Readonly my %RTL_LANGS => (map { $_ => 1 } # ISO 639-1 codes whose primary script is RTL | |||||
| 170 | qw(ar dv fa he ku ps sd ug ur yi)); | |||||
| 171 | ||||||
| 172 - 180 | =head1 NAME CGI::Lingua - Create a multilingual web page =head1 VERSION Version 0.87 =cut | |||||
| 181 | ||||||
| 182 - 537 | =head1 SYNOPSIS
Your website tells CGI::Lingua which languages it can show.
CGI::Lingua looks at what the visitor's web browser asks for,
and tells your website which of its languages to use.
=head2 Pick a language for the page
use CGI::Lingua;
# The site has pages in English and French
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
my $language = $l->language(); # 'English', 'French' or 'Unknown'
if($language eq 'English') {
print "<p>Hello</p>\n";
} elsif($language eq 'French') {
print "<p>Bonjour</p>\n";
} else {
# The visitor wants a language that the site does not have
my $wanted = $l->requested_language(); # e.g. 'German'
print "<p>Sorry, this page is not available in $wanted.</p>\n";
}
=head2 Use the short code to choose a file or template
my $l = CGI::Lingua->new(supported => ['en-gb', 'en-us', 'fr']);
my $code = $l->language_code_alpha2() // 'en'; # 'en' or 'fr'
my $variant = $l->sublanguage_code_alpha2(); # 'gb', 'us' or undef
my $template = defined($variant) ? "$code-$variant.tmpl" : "$code.tmpl";
=head2 Show different content for different countries
my $l = CGI::Lingua->new(supported => ['en']);
my $country = $l->country() // ''; # e.g. 'us', 'ca', 'gb'
if($country eq 'us') {
print "Call us on 1-800-555-0100\n";
} elsif($country eq 'ca') {
print "Call us on 1-888-555-0100\n";
} else {
print "Email us at help\@example.com\n";
}
=head2 Make it faster with a cache
Finding the country of an IP address can be slow.
Give CGI::Lingua a L<CHI> cache so that the work is only done once
for each visitor.
use CHI;
use CGI::Lingua;
my $cache = CHI->new(
driver => 'File',
root_dir => '/var/cache/myapp',
namespace => 'CGI::Lingua',
);
my $l = CGI::Lingua->new(supported => ['en', 'fr'], cache => $cache);
=head2 Let the visitor choose with a "lang" parameter
If you pass a L<CGI::Info> object, a C<lang=fr> parameter in the URL
is used before the browser's settings.
use CGI::Info;
use CGI::Lingua;
my $info = CGI::Info->new();
my $l = CGI::Lingua->new(supported => ['en', 'fr', 'de'], info => $info);
print $l->language(); # 'German' for https://example.com/page?lang=de
=head2 Set the HTML "lang" and "dir" attributes
my $l = CGI::Lingua->new(supported => ['en', 'ar', 'he']);
my $code = $l->language_code_alpha2() // 'en';
printf qq{<html lang="%s" dir="%s">\n}, $code, $l->text_direction();
# <html lang="ar" dir="rtl"> for an Arabic-speaking visitor
=head2 Choose the right plural form
my $l = CGI::Lingua->new(supported => ['en', 'ru']);
my %messages = (
one => '%d file',
few => '%d files (few)',
many => '%d files (many)',
other => '%d files',
);
my $n = 3;
printf $messages{$l->plural_category($n)} . "\n", $n;
=head2 Load a translation file
# Looks for /var/www/i18n/en-gb.json, then /var/www/i18n/en.json
my $l = CGI::Lingua->new(supported => ['en-gb', 'en', 'fr']);
if(my $file = $l->translation_file('/var/www/i18n')) {
# read and use $file
}
=head2 Guess the visitor's time zone and locale
my $l = CGI::Lingua->new(supported => ['en']);
my $tz = $l->time_zone() // 'UTC'; # e.g. 'America/New_York'
if(my $locale = $l->locale()) { # a Locale::Object::Country
print 'Currency: ', $locale->currency()->code(), "\n";
}
=head2 Do not guess from the IP address
# Only use what the browser asks for; never look up the IP address
my $l = CGI::Lingua->new(supported => ['en', 'fr'], dont_use_ip => 1);
=head1 DESCRIPTION
This section explains, in simple steps, how CGI::Lingua finds the answer.
=head2 Finding the language
When you first call a language method (for example L</language>),
CGI::Lingua looks for the visitor's language in this order:
=over 4
=item 1. The C<lang> parameter of the L<CGI::Info> object, if you gave one
to L</new> with C<info>.
=item 2. The C<HTTP_ACCEPT_LANGUAGE> environment variable.
The web server sets this from the browser's C<Accept-Language> header,
for example C<fr-CA,fr;q=0.9,en;q=0.8>.
Languages with a higher C<q> value are tried first.
A language with C<q=0> means "not acceptable" (RFC 7231) and is never chosen.
Some browsers still send the old tag C<en-uk>; it is treated as C<en-gb>
(the code for the United Kingdom is C<gb>), with a warning.
=item 3. The C<LANG> environment variable.
This is used when you run the program on the command line,
for example C<LANG=fr_FR.UTF-8>.
=item 4. The visitor's country (see below).
CGI::Lingua uses the official language of that country.
This step is skipped when you give C<dont_use_ip> to L</new>.
=back
It then compares the answer with your C<supported> list.
If a visitor asks for C<en-us> and you only support C<en-gb>,
CGI::Lingua still chooses English, because the base language is the same.
If nothing matches, L</language> returns the string C<'Unknown'>.
=head2 Finding the country
L</country> tries these sources in order, and stops at the first answer:
=over 4
=item 1. C<GEOIP_COUNTRY_CODE> (set by Apache's mod_geoip).
=item 2. C<HTTP_CF_IPCOUNTRY> (set by Cloudflare).
=item 3. A local database: L<IP::Country>, then L<Geo::IP>, then L<Geo::IPfree>,
if they are installed.
=item 4. The geoplugin.net web service.
=item 5. A Whois look-up (L<Net::Whois::IP>, then L<Net::Whois::IANA>).
=back
Steps 3 to 5 use the visitor's IP address from C<REMOTE_ADDR>.
Private addresses (such as C<192.168.1.1>) and loopback addresses
(such as C<127.0.0.1>) have no country, so the answer is C<undef>.
=head2 Remembering answers
Each object remembers its answers, so a second call to the same method is fast.
If you give a C<cache> to L</new>, answers are also stored in the cache when the
object is destroyed. The next object created for the same IP address, the same
requested language and the same C<supported> list starts with those answers.
=head1 SUBROUTINES/METHODS
=head2 new
Creates a CGI::Lingua object.
You must tell it which languages your website supports, with C<supported>.
Each language is a short code, such as C<'en'> (English),
C<'fr'> (French) or C<'en-gb'> (British English).
You can give the arguments as a list, as a hash reference,
as a single array reference (the supported list), or as a single string
(one supported language):
CGI::Lingua->new(supported => ['en', 'fr']);
CGI::Lingua->new({ supported => ['en', 'fr'] });
CGI::Lingua->new(['en', 'fr']);
CGI::Lingua->new('en');
The arguments are:
=over 4
=item * C<supported> (required)
The languages your website supports: one short code (a string of 2 to 5
characters) or a reference to an array of language tags.
C<supported_languages> is another name for the same argument.
Each entry in the array must look like a language tag: two or three letters,
optionally followed by subtags, such as C<'en'>, C<'en-gb'>, C<'en_gb'>,
C<'es-419'> or C<'zh-Hant'>. Other entries (C<undef>, references, C<''>, or
words such as C<'english'>) could never match, so they are dropped with the
warning C<"Ignoring '...' in the supported list: not a language code">.
An empty list is allowed; L</language> then always returns C<'Unknown'>.
=item * C<cache> (optional)
An object with C<get()>, C<set()> and C<remove()> methods, such as a L<CHI> object.
CGI::Lingua stores its answers here so that later requests are faster.
If a call to the cache dies (for example a full disc, an unreachable server,
or a L<CHI> object created with C<< on_get_error =E<gt> 'die' >>), CGI::Lingua
warns C<"Cache get failed: ..."> (or C<set> / C<remove>) and carries on as if
the value was not cached. Any method that uses the cache can give this warning.
Everything read back from the cache is checked before it is used, because a
cache can be shared with, or written by, other programs. A value that does not
have the shape CGI::Lingua stores (for example C<< gb<script> >> as a country, or
a hash where a language name should be) is removed and warned about with
C<"Discarding malformed cache entry for ...">, and the answer is worked out
again.
=item * C<config_file> (optional)
The path to a configuration file. It is read by L<Object::Configure>.
Values in this file B<replace> the arguments that you give to C<new()>
(see L</COMMON PITFALLS>).
=item * C<logger> (optional)
Where to send messages. This can be an object with C<warn()>, C<info()> and
C<error()> methods, or any value that L<Object::Configure> accepts
(for example an array reference, which collects the messages).
It is always changed into a L<Log::Abstraction> object.
Without a logger, warnings go to L<Carp>.
=item * C<info> (optional)
A L<CGI::Info> object (or any object with a C<lang()> method).
A C<lang> parameter in the request is then used before the browser's
settings. The parameter comes from the visitor, so it is checked like the
C<Accept-Language> header (see L</ENCODING>); a value that fails is ignored with
the warning C<"lang parameter contains invalid characters; ignoring">.
=item * C<dont_use_ip> (optional, default false)
When true, CGI::Lingua never guesses the language from the visitor's
IP address.
=item * C<syslog> (optional)
Passed on to the logging configuration.
=item * C<debug> (optional, default false)
When true, L<I18N::AcceptLanguage> prints debug information.
=back
If you call C<new()> on an existing object, you get a copy of that object.
The arguments you give replace the values in the copy.
The copy also keeps any answers that the original has already worked out
(see L</COMMON PITFALLS>).
=head3 API SPECIFICATION
=head4 Input
{
supported => { type => 'string|arrayref', optional => 0 },
supported_languages => { type => 'string|arrayref', optional => 1 },
cache => { type => 'object', can => ['get', 'set'], optional => 1 },
config_file => { type => 'string', optional => 1 },
logger => { type => 'object|arrayref|hashref|string', optional => 1 },
info => { type => 'object', can => 'lang', optional => 1 },
dont_use_ip => { type => 'boolean', optional => 1 },
syslog => { type => 'boolean|hashref', optional => 1 },
debug => { type => 'boolean', optional => 1 },
}
You must give C<supported> or its other name C<supported_languages>.
A string must be 2 to 5 characters long.
=head4 Output
{
type => 'object',
isa => 'CGI::Lingua',
}
=head3 EXAMPLE
# The most common form: a list of supported codes
my $l = CGI::Lingua->new(supported => ['en', 'fr', 'de']);
# One supported language
my $l = CGI::Lingua->new(supported => 'en');
# With a cache, a logger and a CGI::Info object
use CHI;
use CGI::Info;
my $l = CGI::Lingua->new({
supported => ['en', 'fr'],
cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp'),
logger => \my @messages,
info => CGI::Info->new(),
});
=head3 MESSAGES
C<new()> dies (with L<Carp/croak>) with one of these messages:
"You must give a list of supported languages"
- supported is missing, undef, 0 or the empty string
"List of supported languages must be an array ref"
- supported is a reference, but not to an array
"Supported languages must be the short code"
- supported is a string shorter than 2 or longer than 5 characters
"Logger must be a blessed object with warn/info/error methods"
- logger is an object that is missing one of these methods
"CGI::Lingua use ->new() not ::new() to instantiate"
- new() was called as a function, with arguments
"info must be an object with a lang() method"
- info is not an object, or has neither lang() nor AUTOLOAD
It warns, and carries on, with:
"Ignoring '...' in the supported list: not a language code"
- an entry of the supported list is not a language tag
"Cache get failed: ..."
- the cache died while looking up saved answers
"Discarding malformed cache entry for ..."
- the saved answers were not in the shape CGI::Lingua writes
=head3 PSEUDOCODE
1. Read the arguments with Params::Get
2. If called on an object, return a copy with the new arguments on top
3. If logger is an object, check that it has warn, info and error
4. Merge in config_file and environment settings with Object::Configure
5. Check supported (required; string of 2-5 characters, or an arrayref)
6. If there is a cache and REMOTE_ADDR is set, try to load saved answers
from the cache (JSON); if found, return them as an object
7. Otherwise return a new object with no answers yet
=cut | |||||
| 538 | ||||||
| 539 | sub new | |||||
| 540 | { | |||||
| 541 | 2089 | 48241289 | my $class = shift; | |||
| 542 | 2089 | 3262 | my $params = Params::Get::get_params('supported', @_); | |||
| 543 | ||||||
| 544 | # Handle ::new() misuse | |||||
| 545 | 2085 | 32222 | if(!defined($class)) { | |||
| 546 | 3 | 7 | if($params) { | |||
| 547 | # Object::Configure has not run yet, so the logger may still be in | |||||
| 548 | # one of its raw forms (arrayref, hashref, file name) | |||||
| 549 | 3 | 4 | my $logger = $params->{'logger'}; | |||
| 550 | 3 | 8 | if(Scalar::Util::blessed($logger) && $logger->can('error')) { | |||
| 551 | 0 | 0 | $logger->error(__PACKAGE__ . ' use ->new() not ::new() to instantiate'); | |||
| 552 | } | |||||
| 553 | 3 | 19 | Carp::croak(__PACKAGE__ . ' use ->new() not ::new() to instantiate'); | |||
| 554 | } | |||||
| 555 | 0 | 0 | $class = __PACKAGE__; | |||
| 556 | } elsif(ref($class)) { | |||||
| 557 | # Clone: overlay new params onto existing object state | |||||
| 558 | 6 | 21 | $params->{_supported} ||= $params->{supported} if defined $params->{'supported'}; | |||
| 559 | 6 6 6 | 6 11 24 | return bless { %{$class}, %{$params} }, ref($class); | |||
| 560 | } | |||||
| 561 | ||||||
| 562 | # Validate blessed logger objects before Object::Configure runs. | |||||
| 563 | # Non-blessed values (arrayrefs, hashrefs) are valid config forms that | |||||
| 564 | # Object::Configure knows how to convert into a Log::Abstraction instance. | |||||
| 565 | 2076 | 2525 | if(defined $params->{'logger'} && Scalar::Util::blessed($params->{'logger'})) { | |||
| 566 | 8 | 61 | unless( | |||
| 567 | $params->{'logger'}->can('warn') | |||||
| 568 | && $params->{'logger'}->can('info') | |||||
| 569 | && $params->{'logger'}->can('error') | |||||
| 570 | ) { | |||||
| 571 | 4 | 171 | Carp::croak('Logger must be a blessed object with warn/info/error methods'); | |||
| 572 | } | |||||
| 573 | } | |||||
| 574 | ||||||
| 575 | # Object::Configure runs evals and file tests; keep them off the caller's $@ and $! | |||||
| 576 | 2072 2072 2072 | 1410 8772 2866 | $params = do { local ($@, $!); Object::Configure::configure($class, $params) }; | |||
| 577 | ||||||
| 578 | # Normalise supported / supported_languages alias | |||||
| 579 | 2072 | 4931906 | $params->{'supported'} ||= $params->{'supported_languages'}; | |||
| 580 | 2072 | 2669 | if(defined($params->{supported})) { | |||
| 581 | # Validate supported type/length | |||||
| 582 | 2062 | 3543 | if(ref($params->{supported})) { | |||
| 583 | 1027 | 1598 | if(ref($params->{supported}) ne 'ARRAY') { | |||
| 584 | 249 | 2977 | Carp::croak('List of supported languages must be an array ref'); | |||
| 585 | } | |||||
| 586 | } elsif((length($params->{supported}) < 2) || (length($params->{supported}) > 5)) { | |||||
| 587 | 189 | 2492 | Carp::croak('Supported languages must be the short code'); | |||
| 588 | } | |||||
| 589 | } else { | |||||
| 590 | 10 | 20 | if(my $logger = $params->{'logger'}) { | |||
| 591 | 10 | 24 | $logger->error('You must give a list of supported languages'); | |||
| 592 | } | |||||
| 593 | 10 | 4248 | Carp::croak('You must give a list of supported languages'); | |||
| 594 | } | |||||
| 595 | ||||||
| 596 | 1624 | 1136 | my $cache = $params->{cache}; | |||
| 597 | 1624 | 1188 | my $info = $params->{info}; | |||
| 598 | ||||||
| 599 | # info is asked for lang() on every request. CGI::Info provides lang() | |||||
| 600 | # through AUTOLOAD, so can('lang') alone is not enough of a test. | |||||
| 601 | 1624 | 1873 | if(defined($info) && !(Scalar::Util::blessed($info) && ($info->can('lang') || $info->can('AUTOLOAD')))) { | |||
| 602 | 3 | 29 | Carp::croak('info must be an object with a lang() method'); | |||
| 603 | } | |||||
| 604 | ||||||
| 605 | # Keep only entries that are language tags ("en", "en-gb", "es-419"). | |||||
| 606 | # undef, references (including a list that contains itself), '' and words | |||||
| 607 | # such as "english" could never match and would make I18N::AcceptLanguage | |||||
| 608 | # warn; say so rather than drop them silently. | |||||
| 609 | 1621 | 1042 | my @supported; | |||
| 610 | 1621 775 | 1780 828 | for my $item (ref($params->{supported}) ? @{$params->{supported}} : ($params->{'supported'})) { | |||
| 611 | 2762 | 142475 | my $entry = $item; # a copy: the loop variable aliases the caller's array | |||
| 612 | 2762 | 7112 | if(defined($entry) && !ref($entry) && ($entry =~ $SUPPORTED_RE)) { | |||
| 613 | 1537 | 6954 | if(lc($entry) eq $DEPRECATED_EN_UK) { | |||
| 614 | # Same rule as the header: en-uk is en-gb | |||||
| 615 | 2 | 10 | my $msg = "Resetting country code to GB for $entry"; | |||
| 616 | 2 | 16 | if(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn')) { | |||
| 617 | 2 | 8 | $params->{'logger'}->warn($msg); | |||
| 618 | } else { | |||||
| 619 | 0 | 0 | Carp::carp($msg); | |||
| 620 | } | |||||
| 621 | 2 | 461 | $entry = $CANONICAL_EN_GB; | |||
| 622 | } | |||||
| 623 | 1537 | 3595 | push @supported, $entry; | |||
| 624 | 1537 | 1605 | next; | |||
| 625 | } | |||||
| 626 | 1225 | 5062 | my $msg = q{Ignoring '} . _printable($entry) . q{' in the supported list: not a language code}; | |||
| 627 | 1225 | 2559 | if(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn')) { | |||
| 628 | 1225 | 4671 | $params->{'logger'}->warn($msg); | |||
| 629 | } else { | |||||
| 630 | 0 | 0 | Carp::carp($msg); | |||
| 631 | } | |||||
| 632 | } | |||||
| 633 | ||||||
| 634 | my $self = bless { | |||||
| 635 | 1621 | 7692 | %{$params}, | |||
| 636 | _supported => \@supported, | |||||
| 637 | _cache => $cache, | |||||
| 638 | _info => $info, | |||||
| 639 | _syslog => $params->{syslog}, | |||||
| 640 | _dont_use_ip => $params->{dont_use_ip} || 0, | |||||
| 641 | _have_ipcountry => $GEO_UNKNOWN, | |||||
| 642 | _have_geoip => $GEO_UNKNOWN, | |||||
| 643 | _have_geoipfree => $GEO_UNKNOWN, | |||||
| 644 | 1621 | 157161 | _debug => $params->{debug} || 0, | |||
| 645 | }, $class; | |||||
| 646 | ||||||
| 647 | # Try to restore the answers saved by DESTROY for this visitor. Only the | |||||
| 648 | # fields in %RESTORABLE are taken from the cache, each checked against | |||||
| 649 | # its pattern: the cache can be written by others (world-writable /tmp, | |||||
| 650 | # unauthenticated Redis), so a hostile entry must not set the logger, | |||||
| 651 | # the supported list, dont_use_ip, or an HTML/path payload as a language. | |||||
| 652 | 1621 | 9554 | if($cache && $ENV{'REMOTE_ADDR'}) { | |||
| 653 | 77 | 126 | my $key = _build_cache_key($ENV{'REMOTE_ADDR'}, $params, $class, $info); | |||
| 654 | # No key means REMOTE_ADDR is not a valid address: do not cache | |||||
| 655 | 77 | 167 | if(defined($key) && (my $frozen = _cache_call($params, $cache, 'get', $key))) { | |||
| 656 | # JSON::PP rather than Storable::thaw: Storable can run code via | |||||
| 657 | # STORABLE_thaw hooks in a crafted blob; JSON cannot. A blob that | |||||
| 658 | # is not JSON (e.g. a legacy Storable entry) is quietly rebuilt. | |||||
| 659 | 23 23 23 23 23 | 19 16 17 24 42 | my $rc = do { local $@; eval { local $SIG{__DIE__}; JSON::PP::decode_json($frozen) } }; | |||
| 660 | 23 | 9872 | if(ref($rc) eq 'HASH') { | |||
| 661 | my @bad = grep { | |||||
| 662 | 18 108 | 47 559 | defined($rc->{$_}) && (ref($rc->{$_}) || ($rc->{$_} !~ $RESTORABLE{$_})) | |||
| 663 | } sort keys %RESTORABLE; | |||||
| 664 | 18 | 44 | if(@bad) { | |||
| 665 | 5 | 8 | my $msg = "Discarding malformed cache entry for $key"; | |||
| 666 | 5 | 33 | if(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn')) { | |||
| 667 | 5 | 20 | $params->{'logger'}->warn($msg); | |||
| 668 | } else { | |||||
| 669 | 0 | 0 | Carp::carp($msg); | |||
| 670 | } | |||||
| 671 | 5 | 2046 | _cache_call($params, $cache, 'remove', $key); | |||
| 672 | } else { | |||||
| 673 | 13 78 | 15 196 | $self->{$_} = $rc->{$_} for grep { defined $rc->{$_} } keys %RESTORABLE; | |||
| 674 | ||||||
| 675 | # A lang= CGI parameter overrides the browser, so the | |||||
| 676 | # cached language choice may be stale: work it out again | |||||
| 677 | 13 | 30 | if(_info_lang($info)) { | |||
| 678 | 0 0 | 0 0 | delete @{$self}{qw(_slanguage _rlanguage _sublanguage | |||
| 679 | _slanguage_code_alpha2 _sublanguage_code_alpha2)}; | |||||
| 680 | } | |||||
| 681 | } | |||||
| 682 | } | |||||
| 683 | } | |||||
| 684 | } | |||||
| 685 | ||||||
| 686 | 1621 | 3103 | return $self; | |||
| 687 | } | |||||
| 688 | # ââ _build_cache_key ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 689 | # Purpose: Produce a deterministic string key for the per-request cache | |||||
| 690 | # entry stored in new() and DESTROY(). | |||||
| 691 | # Entry: $addr â IP string (not yet taint-checked; used read-only here) | |||||
| 692 | # $params â constructor params hashref | |||||
| 693 | # $class â package name | |||||
| 694 | # $info â optional CGI::Info object | |||||
| 695 | # Exit: A plain string key of the form "ip/lang/lang1/lang2/..." | |||||
| 696 | sub _build_cache_key | |||||
| 697 | { | |||||
| 698 | 169 | 56544 | my ($addr, $params, $class, $info) = @_; | |||
| 699 | ||||||
| 700 | # Every part of the key comes from the visitor or the caller, and cache | |||||
| 701 | # backends such as Memcached treat CR, LF and spaces in a key as protocol. | |||||
| 702 | # So the key is built only from checked values, and there is no key (and | |||||
| 703 | # so no caching) when the address is not a valid one. | |||||
| 704 | 169 | 209 | my $ip = _untaint_ip($addr); | |||
| 705 | 169 | 945 | return undef unless defined($ip); | |||
| 706 | 169 | 137 | my $key = "$ip/"; | |||
| 707 | ||||||
| 708 | # Include the requested language (if determinable) so different | |||||
| 709 | # Accept-Language values get distinct cache slots for the same IP. | |||||
| 710 | # Both sources are validated; spaces are not significant in the header. | |||||
| 711 | 169 | 123 | my $l; | |||
| 712 | 169 | 199 | if(defined($l = _info_lang($info)) || defined($l = $class->_what_language())) { | |||
| 713 | 124 | 133 | $l =~ s/[ \t]+//g; | |||
| 714 | 124 | 165 | $key .= "$l/" if length($l); | |||
| 715 | } | |||||
| 716 | ||||||
| 717 | # Fix: was ref($params->{'supported'} eq 'ARRAY') â eq was inside ref(), | |||||
| 718 | # so ref() always received a boolean (1 or ''), never the arrayref itself. | |||||
| 719 | # Only entries that new() would keep are used. | |||||
| 720 | 169 168 | 238 195 | my @supported = ref($params->{'supported'}) eq 'ARRAY' ? @{$params->{supported}} : ($params->{'supported'}); | |||
| 721 | 169 317 | 156 903 | $key .= join('/', grep { defined($_) && !ref($_) && ($_ =~ $SUPPORTED_RE) } @supported); | |||
| 722 | ||||||
| 723 | 169 | 814 | return $key; | |||
| 724 | } | |||||
| 725 | ||||||
| 726 | # ââ _untaint_ip ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 727 | # Purpose: Check that a value has the shape of an IP address and return | |||||
| 728 | # an untainted copy. The single place REMOTE_ADDR is checked, | |||||
| 729 | # so country(), time_zone() and the cache key always agree. | |||||
| 730 | # Entry: $raw â any scalar. | |||||
| 731 | # Exit: The address, or undef. \z, not $, so "1.2.3.4\n" is refused. | |||||
| 732 | # Notes: Shape only: country() then range-checks with is_ipv4/is_ipv6. | |||||
| 733 | sub _untaint_ip | |||||
| 734 | { | |||||
| 735 | 455 | 370 | my $raw = shift; | |||
| 736 | 455 | 820 | return undef unless defined($raw) && !ref($raw); | |||
| 737 | 455 | 649 | return $raw =~ $IP_SHAPE_RE ? $1 : undef; | |||
| 738 | } | |||||
| 739 | ||||||
| 740 | # ââ _cache_call ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 741 | # Purpose: Call get/set/remove on the caller-supplied cache without | |||||
| 742 | # letting a cache failure take down the request. The cache only | |||||
| 743 | # saves time, so a broken backend (full disc, unreachable Redis, | |||||
| 744 | # CHI with on_get_error => 'die') must degrade to "not cached". | |||||
| 745 | # Entry: $self â a CGI::Lingua object, or new()'s params hashref | |||||
| 746 | # (used only to find a logger); | |||||
| 747 | # $cache â the cache object; $method â 'get', 'set' or 'remove'; | |||||
| 748 | # @args â passed to the method unchanged. | |||||
| 749 | # Exit: The method's scalar result, or undef if it died. | |||||
| 750 | # Side Effects: Warns "Cache <method> failed: <error>" on failure. | |||||
| 751 | sub _cache_call | |||||
| 752 | { | |||||
| 753 | 445 | 6972 | my ($self, $cache, $method, @args) = @_; | |||
| 754 | ||||||
| 755 | 445 | 514 | local $@; | |||
| 756 | 445 | 261 | my $rc; | |||
| 757 | 445 445 445 408 | 251 538 892 32164 | return $rc if eval { local $SIG{__DIE__}; $rc = $cache->$method(@args); 1 }; | |||
| 758 | ||||||
| 759 | 37 | 11484 | my $err = $@ || 'unknown error'; | |||
| 760 | 37 | 125 | $err =~ s/\s+\z//; | |||
| 761 | 37 | 52 | my $msg = "Cache $method failed: $err"; | |||
| 762 | 37 | 96 | if(Scalar::Util::blessed($self)) { | |||
| 763 | 32 | 49 | $self->_warn({ warning => $msg }); | |||
| 764 | } elsif((ref($self) eq 'HASH') && Scalar::Util::blessed($self->{'logger'}) && $self->{'logger'}->can('warn')) { | |||||
| 765 | 5 | 9 | $self->{'logger'}->warn($msg); | |||
| 766 | } else { | |||||
| 767 | 0 | 0 | Carp::carp($msg); | |||
| 768 | } | |||||
| 769 | 37 | 5064 | return; | |||
| 770 | } | |||||
| 771 | ||||||
| 772 | # ââ _info_lang âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 773 | # Purpose: Ask the CGI::Info-style object for the lang= parameter without | |||||
| 774 | # letting a broken object (an AUTOLOAD that dies) end the request. | |||||
| 775 | # Entry: $info â the info object, or undef; | |||||
| 776 | # $self â optional object to warn through when the value is bad. | |||||
| 777 | # Exit: The validated, untainted lang value, or undef if there is none, | |||||
| 778 | # the call died, or the value is not a plausible language list. | |||||
| 779 | sub _info_lang | |||||
| 780 | { | |||||
| 781 | 186 | 166 | my ($info, $self) = @_; | |||
| 782 | ||||||
| 783 | 186 | 468 | return undef unless $info; | |||
| 784 | 13 | 10 | local $@; | |||
| 785 | 13 | 10 | my $lang; | |||
| 786 | 13 13 13 10 | 52 30 24 43 | return undef unless eval { local $SIG{__DIE__}; $lang = $info->lang(); 1 }; | |||
| 787 | 10 | 31 | return undef unless defined($lang) && length($lang); | |||
| 788 | ||||||
| 789 | # lang= comes from the query string, so it is as hostile as any header: | |||||
| 790 | # same character set and length cap as HTTP_ACCEPT_LANGUAGE | |||||
| 791 | 6 | 22 | return $1 if !ref($lang) && ($lang =~ $ACCEPT_LANG_RE); | |||
| 792 | 1 | 13 | $self->_warn({ warning => 'lang parameter contains invalid characters; ignoring' }) if ref($self); | |||
| 793 | 1 | 7 | return undef; | |||
| 794 | } | |||||
| 795 | ||||||
| 796 | # ââ _cache_get_valid âââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 797 | # Purpose: Read a value from the cache and use it only if it has the | |||||
| 798 | # shape CGI::Lingua itself stores. The cache may be shared or | |||||
| 799 | # writable by others; a poisoned entry ("<script>", a hashref, | |||||
| 800 | # "../../etc") must never become a language or country. | |||||
| 801 | # Entry: $key â full cache key; $re â the pattern the value must match. | |||||
| 802 | # Exit: The value, or undef on a miss or a malformed value. | |||||
| 803 | # Side Effects: A malformed value is removed and "Discarding malformed cache | |||||
| 804 | # entry for <key>" is warned (the value itself is not logged: | |||||
| 805 | # it is attacker-controlled). | |||||
| 806 | sub _cache_get_valid | |||||
| 807 | { | |||||
| 808 | 101 | 341 | my ($self, $key, $re) = @_; | |||
| 809 | ||||||
| 810 | 101 | 290 | my $value = $self->_cache_call($self->{_cache}, 'get', $key); | |||
| 811 | 101 | 308 | return $value if !defined($value) || (!ref($value) && ($value =~ $re)); | |||
| 812 | ||||||
| 813 | 3 | 8 | $self->_warn({ warning => "Discarding malformed cache entry for $key" }); | |||
| 814 | 3 | 10 | $self->_cache_call($self->{_cache}, 'remove', $key); | |||
| 815 | 3 | 5 | return; | |||
| 816 | } | |||||
| 817 | ||||||
| 818 | # Some of the information takes a long time to work out, so cache what we can | |||||
| 819 | sub DESTROY { | |||||
| 820 | # Destructors run at arbitrary points, e.g. while the caller is examining $@ | |||||
| 821 | 1616 | 341594 | local ($@, $!); | |||
| 822 | 1616 | 11632 | if(defined($^V) && ($^V ge 'v5.14.0')) { | |||
| 823 | 1616 | 2339 | return if ${^GLOBAL_PHASE} eq 'DESTRUCT'; | |||
| 824 | } | |||||
| 825 | 1616 | 11372 | return unless $ENV{'REMOTE_ADDR'}; | |||
| 826 | ||||||
| 827 | 380 | 293 | my $self = shift; | |||
| 828 | 380 | 393 | return unless ref($self); | |||
| 829 | ||||||
| 830 | 380 | 557 | my $cache = $self->{_cache}; | |||
| 831 | 380 | 12140 | return unless $cache; | |||
| 832 | ||||||
| 833 | my $key = _build_cache_key( | |||||
| 834 | $ENV{'REMOTE_ADDR'}, | |||||
| 835 | { supported => $self->{_supported} }, | |||||
| 836 | ref($self), | |||||
| 837 | $self->{_info}, | |||||
| 838 | 79 | 183 | ); | |||
| 839 | 79 | 106 | return unless defined($key); # REMOTE_ADDR is not a valid address | |||
| 840 | 79 | 98 | return if $self->_cache_call($cache, 'get', $key); | |||
| 841 | ||||||
| 842 | 59 | 98 | $self->_debug("Storing self in cache as $key"); | |||
| 843 | ||||||
| 844 | # Serialise only the computed state â not loggers, file handles, or | |||||
| 845 | # geo-module objects (they are re-initialised on next construction). | |||||
| 846 | # JSON::PP is used instead of Storable so that a compromised cache backend | |||||
| 847 | # cannot deliver a blob that executes code via STORABLE_thaw hooks. | |||||
| 848 | # Only the fields new() will accept back (see %RESTORABLE). _sublanguage | |||||
| 849 | # used to be left out, so sublanguage() was undef after a restore. | |||||
| 850 | 59 354 | 1689 1039 | my %state = map { $_ => $self->{$_} } keys %RESTORABLE; | |||
| 851 | ||||||
| 852 | 59 | 187 | $self->_cache_call($cache, 'set', $key, JSON::PP::encode_json(\%state), $CACHE_TTL_LONG); | |||
| 853 | } | |||||
| 854 | ||||||
| 855 - 906 | =head2 language
Returns the name of the language to show to the visitor, in English,
for example C<'English'>, C<'French'> or C<'Japanese'>.
The language is always one of the languages in your C<supported> list.
Variants are handled sensibly.
If a visitor asks for American English (C<en-us>)
and your site only has British English (C<en-gb>),
C<language()> returns C<'English'>.
If none of the languages that the visitor wants is in your C<supported> list,
C<language()> returns the string C<'Unknown'>.
It never returns C<undef>.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 1,
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'fr,en;q=0.9';
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
print $l->language(); # "French"
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'de';
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
print $l->language(); # "Unknown"
=head3 MESSAGES
The first call to L</language> (or any other language method) warns, and
ignores the value, when one of its inputs is not acceptable:
"lang parameter contains invalid characters; ignoring"
"HTTP_ACCEPT_LANGUAGE contains invalid characters; ignoring"
"LANG contains invalid characters; ignoring"
It also warns when it changes the deprecated tag C<en-uk> into C<en-gb>:
"Resetting country code to GB for ..."
=cut | |||||
| 907 | ||||||
| 908 | sub language { | |||||
| 909 | 283 | 11249 | my $self = $_[0]; | |||
| 910 | ||||||
| 911 | 283 | 628 | $self->_find_language() unless $self->{_slanguage}; | |||
| 912 | 283 | 1326 | return $self->{_slanguage}; | |||
| 913 | } | |||||
| 914 | ||||||
| 915 - 932 | =head2 preferred_language
Another name for L</language>. It takes no arguments and returns the same value.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 1,
}
=cut | |||||
| 933 | ||||||
| 934 | sub preferred_language | |||||
| 935 | { | |||||
| 936 | 9 | 18 | my $self = shift; | |||
| 937 | 9 | 18 | return $self->language(@_); | |||
| 938 | } | |||||
| 939 | ||||||
| 940 - 958 | =head2 name
Another name for L</language>, so that a CGI::Lingua object can be used where a
L<Locale::Object::Language> object is expected.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 1,
}
=cut | |||||
| 959 | ||||||
| 960 | sub name { | |||||
| 961 | 9 | 16 | my $self = $_[0]; | |||
| 962 | 9 | 17 | return $self->language(); | |||
| 963 | } | |||||
| 964 | ||||||
| 965 - 993 | =head2 sublanguage
Returns the name of the variant (usually a country) of the chosen language,
for example C<'United Kingdom'> when the chosen language is C<en-gb>.
Returns C<undef> when there is no variant, for example when the chosen
language is just C<en>, or when no language was found.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 1,
optional => 1,
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage(); # "United Kingdom"
=cut | |||||
| 994 | ||||||
| 995 | sub sublanguage { | |||||
| 996 | 62 | 777 | my $self = $_[0]; | |||
| 997 | ||||||
| 998 | 62 | 111 | $self->_trace('Entered sublanguage'); | |||
| 999 | 62 | 1741 | $self->_find_language() unless $self->{_slanguage}; | |||
| 1000 | 62 | 129 | $self->_trace('Leaving sublanguage ', ($self->{_sublanguage} || 'undef')); | |||
| 1001 | 62 | 1463 | return $self->{_sublanguage}; | |||
| 1002 | } | |||||
| 1003 | ||||||
| 1004 - 1033 | =head2 language_code_alpha2
Returns the two-letter code (ISO 639-1) of the chosen language,
for example C<'en'> when the chosen language is C<en-gb>.
Returns C<undef> when none of the languages that the visitor wants is in your
C<supported> list (that is, when L</language> returns C<'Unknown'>).
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 2,
max => 2,
optional => 1,
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->language_code_alpha2(); # "en"
=cut | |||||
| 1034 | ||||||
| 1035 | sub language_code_alpha2 { | |||||
| 1036 | 115 | 95 | my $self = $_[0]; | |||
| 1037 | ||||||
| 1038 | 115 | 149 | $self->_trace('Entered language_code_alpha2'); | |||
| 1039 | 115 | 3842 | $self->_find_language() unless $self->{_slanguage}; | |||
| 1040 | 115 | 138 | $self->_trace('language_code_alpha2 returns ', $self->{_slanguage_code_alpha2}); | |||
| 1041 | 115 | 3112 | return $self->{_slanguage_code_alpha2}; | |||
| 1042 | } | |||||
| 1043 | ||||||
| 1044 - 1063 | =head2 code_alpha2
Another name for L</language_code_alpha2>, kept so that old programs still work.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 2,
max => 2,
optional => 1,
}
=cut | |||||
| 1064 | ||||||
| 1065 | sub code_alpha2 { | |||||
| 1066 | 16 | 564 | my $self = $_[0]; | |||
| 1067 | 16 | 24 | return $self->language_code_alpha2(); | |||
| 1068 | } | |||||
| 1069 | ||||||
| 1070 - 1098 | =head2 sublanguage_code_alpha2
Returns the two-letter code of the variant of the chosen language,
in lower case, for example C<'gb'> when the chosen language is C<en-gb>.
Returns C<undef> when there is no variant.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 2,
max => 2,
optional => 1,
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage_code_alpha2(); # "gb"
=cut | |||||
| 1099 | ||||||
| 1100 | sub sublanguage_code_alpha2 { | |||||
| 1101 | 51 | 784 | my $self = $_[0]; | |||
| 1102 | ||||||
| 1103 | 51 | 69 | $self->_find_language() unless $self->{_slanguage}; | |||
| 1104 | 51 | 110 | return $self->{_sublanguage_code_alpha2}; | |||
| 1105 | } | |||||
| 1106 | ||||||
| 1107 - 1145 | =head2 requested_language
Returns, in English, the language that the visitor asked for,
B<whether or not your site supports it>.
Use it to tell the visitor that their language is not available.
If the visitor asked for a variant, it is shown in brackets,
for example C<'English (United Kingdom)'>. A variant that is not a known
country code is shown as it was sent, for example C<'English (Unknown: zz)'>.
Returns C<'Unknown'> when the visitor's language cannot be found at all.
It never returns C<undef>.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
min => 1,
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->language(); # "English"
print $l->requested_language(); # "English (United Kingdom)"
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'de';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->language(); # "Unknown"
print $l->requested_language(); # "German"
=cut | |||||
| 1146 | ||||||
| 1147 | sub requested_language { | |||||
| 1148 | 77 | 8352 | my $self = $_[0]; | |||
| 1149 | ||||||
| 1150 | 77 | 119 | $self->_find_language() unless $self->{_rlanguage}; | |||
| 1151 | # _find_language() can leave it undef; the API promises a string | |||||
| 1152 | 77 | 155 | $self->{_rlanguage} //= 'Unknown'; | |||
| 1153 | 77 | 157 | return $self->{_rlanguage}; | |||
| 1154 | } | |||||
| 1155 | ||||||
| 1156 | # ââ _find_language âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1157 | # Purpose: Populate _slanguage, _rlanguage, _sublanguage, and the | |||||
| 1158 | # various code fields by working through the detection pipeline: | |||||
| 1159 | # Accept-Language header â I18N::AcceptLanguage â IP country. | |||||
| 1160 | # Entry: $self->{_slanguage} must be undef (guards repeated calls). | |||||
| 1161 | # Exit: $self->{_slanguage} is set to a language name or 'Unknown'. | |||||
| 1162 | # Side Effects: Populates _rlanguage, _sublanguage, *_code_alpha2 fields. | |||||
| 1163 | sub _find_language | |||||
| 1164 | { | |||||
| 1165 | 322 | 1006 | my $self = shift; | |||
| 1166 | # Called by every language accessor; the evals and file tests below must | |||||
| 1167 | # not change the caller's $@ or $! | |||||
| 1168 | 322 | 1068 | local ($@, $!); | |||
| 1169 | ||||||
| 1170 | 322 | 481 | $self->_trace('Entered _find_language'); | |||
| 1171 | ||||||
| 1172 | 322 | 10190 | $self->{_rlanguage} = 'Unknown'; | |||
| 1173 | 322 | 330 | $self->{_slanguage} = 'Unknown'; | |||
| 1174 | ||||||
| 1175 | 322 | 433 | my $http_accept_language = $self->_what_language(); | |||
| 1176 | ||||||
| 1177 | # RFC 7231 section 5.3.1: q=0 means "not acceptable", so drop those tags | |||||
| 1178 | # before matching, or "fr;q=0, en;q=0.5" would choose French. A q value | |||||
| 1179 | # outside the RFC grammar (0 to 1, at most three decimals: "q=abc", | |||||
| 1180 | # "q=1e309", "q=-1") makes the tag malformed, so drop it too, rather than | |||||
| 1181 | # let I18N::AcceptLanguage guess and warn. | |||||
| 1182 | 322 | 630 | if(defined($http_accept_language) && ($http_accept_language =~ /;\s*q\s*=/i)) { | |||
| 1183 | 46 | 44 | my @acceptable; | |||
| 1184 | 46 | 117 | for my $tag (split(/\s*,\s*/, $http_accept_language)) { | |||
| 1185 | 105 | 216 | if($tag =~ /;\s*q\s*=\s*(\S*?)\s*\z/i) { | |||
| 1186 | 71 | 59 | my $q = $1; | |||
| 1187 | 71 | 150 | next unless $q =~ /^(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)\z/; | |||
| 1188 | 66 | 149 | next if $q == 0; | |||
| 1189 | } | |||||
| 1190 | 92 | 97 | push @acceptable, $tag; | |||
| 1191 | } | |||||
| 1192 | 46 | 84 | $http_accept_language = @acceptable ? join(',', @acceptable) : undef; | |||
| 1193 | 46 | 91 | $self->_debug('Accept-Language after dropping unacceptable tags: ', $http_accept_language // '(none)'); | |||
| 1194 | } | |||||
| 1195 | ||||||
| 1196 | 322 | 1671 | if(defined($http_accept_language)) { | |||
| 1197 | $self->_debug( | |||||
| 1198 | "language wanted: $http_accept_language, " | |||||
| 1199 | . 'languages supported: ' | |||||
| 1200 | 237 237 | 276 564 | . join(', ', @{$self->{_supported}} // '') | |||
| 1201 | ); | |||||
| 1202 | ||||||
| 1203 | # Normalise the deprecated en-uk tag that some browsers send, wherever | |||||
| 1204 | # it is in the list (not only when it is the whole header) | |||||
| 1205 | 237 | 6461 | my $sent = $http_accept_language; | |||
| 1206 | 237 | 262 | if($http_accept_language =~ s/(?<![A-Za-z0-9-])\Q$DEPRECATED_EN_UK\E(?![A-Za-z0-9-])/$CANONICAL_EN_GB/gi) { | |||
| 1207 | 7 | 66 | $self->_warn({ warning => "Resetting country code to GB for $sent" }); | |||
| 1208 | } | |||||
| 1209 | ||||||
| 1210 | # Run the header through the Accept-Language resolver | |||||
| 1211 | 237 | 2797 | my ($l, $requested_sublanguage) = | |||
| 1212 | $self->_accept_language_match($http_accept_language); | |||||
| 1213 | ||||||
| 1214 | # Resolve the matched code to a full language/sublanguage | |||||
| 1215 | 237 | 308 | if($l) { | |||
| 1216 | 204 | 269 | return if $self->_resolve_match($l, $requested_sublanguage, $http_accept_language); | |||
| 1217 | } elsif($http_accept_language =~ /;/) { | |||||
| 1218 | # e.g. de-DE,de;q=0.9,en-US;q=0.8 and we support none of those | |||||
| 1219 | $self->_notice( | |||||
| 1220 | __PACKAGE__, ': ', __LINE__, | |||||
| 1221 | ": couldn't honour HTTP_ACCEPT_LANGUAGE=$http_accept_language," | |||||
| 1222 | . ' supported languages are: ' | |||||
| 1223 | 4 4 | 5 12 | . join(',', @{$self->{_supported}}) | |||
| 1224 | ); | |||||
| 1225 | } | |||||
| 1226 | ||||||
| 1227 | # Last-chance: 2-char or xx-xx header where we have no match | |||||
| 1228 | 35 | 267 | if( | |||
| 1229 | ((!$self->{_rlanguage}) || ($self->{_rlanguage} eq 'Unknown')) | |||||
| 1230 | && ((length($http_accept_language) == 2) || ($http_accept_language =~ /^..-..$/)) | |||||
| 1231 | ) { | |||||
| 1232 | 23 | 61 | $self->{_rlanguage} = $self->_code2language($http_accept_language) || 'Unknown'; | |||
| 1233 | } | |||||
| 1234 | 35 | 86826 | $self->{_slanguage} = 'Unknown'; | |||
| 1235 | } | |||||
| 1236 | ||||||
| 1237 | 120 | 186 | return if $self->{_dont_use_ip}; | |||
| 1238 | ||||||
| 1239 | # Fall back to the official language of the visitor's country | |||||
| 1240 | 92 | 164 | $self->_find_language_from_ip($http_accept_language); | |||
| 1241 | } | |||||
| 1242 | ||||||
| 1243 | # ââ _accept_language_match ââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1244 | # Purpose: Run I18N::AcceptLanguage strict matching plus two fallback | |||||
| 1245 | # left-to-right scan passes against $self->{_supported}. | |||||
| 1246 | # Entry: $http_accept_language â validated, untainted Accept-Language value. | |||||
| 1247 | # Exit: Returns ($matched_code, $requested_sublanguage) or (undef, undef). | |||||
| 1248 | # Side Effects: Logs debug messages. | |||||
| 1249 | sub _accept_language_match | |||||
| 1250 | { | |||||
| 1251 | 245 | 409 | my ($self, $http_accept_language) = @_; | |||
| 1252 | ||||||
| 1253 | 245 | 739 | my $i18n = I18N::AcceptLanguage->new(debug => $self->{_debug}, strict => 1); | |||
| 1254 | 245 | 17837 | my $l; | |||
| 1255 | { | |||||
| 1256 | # Suppress I18N::AcceptLanguage's uninitialized-value warnings (RT 74338) | |||||
| 1257 | 245 245 | 151 480 | local $SIG{__WARN__} = _warn_filter(qr/^Use of uninitialized value/); | |||
| 1258 | 245 | 395 | $l = $i18n->accepts($http_accept_language, $self->{_supported}); | |||
| 1259 | } | |||||
| 1260 | ||||||
| 1261 | # I18N-AcceptLanguage strict mode can return a sublanguage variant when | |||||
| 1262 | # the request contains a sublanguage we don't support; force a retry. | |||||
| 1263 | 245 | 12550 | if($l && ($http_accept_language =~ /-/) && ($http_accept_language !~ qr/$l/i)) { | |||
| 1264 | 4 | 6 | $self->_debug('Forcing fallback'); | |||
| 1265 | 4 | 83 | undef $l; | |||
| 1266 | } | |||||
| 1267 | ||||||
| 1268 | 245 | 197 | my $requested_sublanguage; | |||
| 1269 | 245 | 250 | if(!$l) { | |||
| 1270 | # Sort tokens by q-value once; both scan passes share the ordered list | |||||
| 1271 | 41 | 69 | my $sorted = $self->_sorted_tokens($http_accept_language); | |||
| 1272 | # First fallback: scan for xx-yy pairs, try base language xx | |||||
| 1273 | 41 | 67 | ($l, $requested_sublanguage) = | |||
| 1274 | $self->_scan_sublanguage_pairs($i18n, $sorted); | |||||
| 1275 | 41 | 60 | if(!$l) { | |||
| 1276 | # Second fallback: scan plain tokens without sublanguages | |||||
| 1277 | 36 | 72 | $l = $self->_scan_plain_tokens($i18n, $sorted); | |||
| 1278 | 36 | 65 | undef $requested_sublanguage if $l; | |||
| 1279 | } | |||||
| 1280 | } | |||||
| 1281 | ||||||
| 1282 | 245 | 492 | return ($l, $requested_sublanguage); | |||
| 1283 | } | |||||
| 1284 | ||||||
| 1285 | # ââ _sorted_tokens ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1286 | # Purpose: Parse an Accept-Language header into tokens sorted by | |||||
| 1287 | # descending quality value so fallback scans honour q= priority. | |||||
| 1288 | # Entry: $header â validated Accept-Language string. | |||||
| 1289 | # Exit: Arrayref of [$language_tag, $quality] pairs, highest q first. | |||||
| 1290 | sub _sorted_tokens | |||||
| 1291 | { | |||||
| 1292 | 56 | 420 | my ($self, $header) = @_; | |||
| 1293 | 56 | 52 | my @tokens; | |||
| 1294 | 56 | 356 | for my $token (split /,/, $header) { | |||
| 1295 | 5088 | 3563 | $token =~ s/^\s+|\s+$//g; | |||
| 1296 | 5088 | 2496 | my $q = 1.0; | |||
| 1297 | 5088 | 2928 | if($token =~ s/;\s*q\s*=\s*(\d+(?:\.\d+)?)//) { | |||
| 1298 | 28 | 54 | $q = $1 + 0; | |||
| 1299 | } | |||||
| 1300 | 5088 | 3343 | $token =~ s/^\s+|\s+$//g; | |||
| 1301 | 5088 | 4843 | push @tokens, [$token, $q] if length $token; | |||
| 1302 | } | |||||
| 1303 | 56 5033 | 334 2638 | return [sort { $b->[1] <=> $a->[1] } @tokens]; | |||
| 1304 | } | |||||
| 1305 | ||||||
| 1306 | # ââ _scan_sublanguage_pairs âââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1307 | # Purpose: Walk q-sorted tokens looking for xx-yy pairs; try accepting | |||||
| 1308 | # the base language xx from the supported list. | |||||
| 1309 | # Entry: $i18n â I18N::AcceptLanguage instance; | |||||
| 1310 | # $sorted â arrayref from _sorted_tokens. | |||||
| 1311 | # Exit: ($matched_code, $sublanguage_code) or (undef, undef). | |||||
| 1312 | # Side Effects: Debug logging. | |||||
| 1313 | sub _scan_sublanguage_pairs | |||||
| 1314 | { | |||||
| 1315 | 43 | 47 | my ($self, $i18n, $sorted) = @_; | |||
| 1316 | ||||||
| 1317 | 43 | 67 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for xx-yy pairs'); | |||
| 1318 | 43 43 | 1382 69 | for my $entry (@{$sorted}) { | |||
| 1319 | 52 52 | 237 55 | my ($tag) = @{$entry}; | |||
| 1320 | 52 | 92 | next unless $tag =~ /^(..)-(..)$/; | |||
| 1321 | 16 | 26 | my ($base, $sub) = ($1, $2); | |||
| 1322 | 16 | 29 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $base is supported"); | |||
| 1323 | 16 | 365 | if($i18n->accepts($base, $self->{_supported})) { | |||
| 1324 | 6 | 196 | $self->_debug("Fallback to $base as sublanguage $sub is not supported"); | |||
| 1325 | 6 | 116 | return ($base, $sub); | |||
| 1326 | } | |||||
| 1327 | } | |||||
| 1328 | 37 | 123 | return (undef, undef); | |||
| 1329 | } | |||||
| 1330 | ||||||
| 1331 | # ââ _scan_plain_tokens ââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1332 | # Purpose: Walk q-sorted tokens that have no sublanguage suffix and try | |||||
| 1333 | # accepting each against the supported list. | |||||
| 1334 | # Entry: $i18n â I18N::AcceptLanguage instance; | |||||
| 1335 | # $sorted â arrayref from _sorted_tokens. | |||||
| 1336 | # Exit: Matched code string, or undef. | |||||
| 1337 | # Side Effects: Debug logging. | |||||
| 1338 | sub _scan_plain_tokens | |||||
| 1339 | { | |||||
| 1340 | 39 | 47 | my ($self, $i18n, $sorted) = @_; | |||
| 1341 | ||||||
| 1342 | 39 | 54 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for plain alternatives'); | |||
| 1343 | 39 39 | 1221 42 | for my $entry (@{$sorted}) { | |||
| 1344 | 47 47 | 100 42 | my ($tag) = @{$entry}; | |||
| 1345 | 47 | 95 | next if $tag =~ /^..-../; # already tried in the pair scan | |||
| 1346 | 39 | 62 | $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $tag is supported"); | |||
| 1347 | 39 | 1199 | if($i18n->accepts($tag, $self->{_supported})) { | |||
| 1348 | 3 | 34 | $self->_debug("Fallback to $tag as best alternative"); | |||
| 1349 | 3 | 66 | return $tag; | |||
| 1350 | } | |||||
| 1351 | } | |||||
| 1352 | 36 | 1096 | return; | |||
| 1353 | } | |||||
| 1354 | ||||||
| 1355 | # ââ _resolve_match ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1356 | # Purpose: Given a matched code $l (possibly xx or xx-yy), populate all | |||||
| 1357 | # of _slanguage, _rlanguage, _sublanguage and their code fields. | |||||
| 1358 | # Entry: $l â 2-char or xx-yy language code; $requested_sublanguage â | |||||
| 1359 | # 2-char variety code or undef; $http_accept_language â full header. | |||||
| 1360 | # Exit: Returns true (1) if the caller should return immediately. | |||||
| 1361 | # Side Effects: Mutates $self->{_slanguage}, _rlanguage, _sublanguage, etc. | |||||
| 1362 | sub _resolve_match | |||||
| 1363 | { | |||||
| 1364 | 209 | 647 | my ($self, $l, $requested_sublanguage, $http_accept_language) = @_; | |||
| 1365 | ||||||
| 1366 | 209 | 304 | $self->_debug("l: $l"); | |||
| 1367 | ||||||
| 1368 | 209 | 6055 | if($l !~ /^..-../) { | |||
| 1369 | # Base-language match (e.g. 'en') â no sublanguage component | |||||
| 1370 | 129 | 188 | return $self->_resolve_base_match($l, $requested_sublanguage, $http_accept_language); | |||
| 1371 | } elsif($l =~ /(.+)-(..)$/) { | |||||
| 1372 | # Sublanguage match (e.g. 'en-gb') â resolve both language and variant | |||||
| 1373 | 78 | 131 | return $self->_resolve_sublanguage_match($l, $1, $2, $http_accept_language); | |||
| 1374 | } | |||||
| 1375 | 2 | 5 | return 0; | |||
| 1376 | } | |||||
| 1377 | ||||||
| 1378 | # ââ _resolve_base_match âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1379 | # Purpose: Handle the case where a base-language code matched (no hyphen). | |||||
| 1380 | # Sets _slanguage, _rlanguage; appends sublanguage name to rlanguage | |||||
| 1381 | # when the client requested one we don't support. | |||||
| 1382 | # Entry: $l â 2-char code; $requested_sublanguage â optional; $header. | |||||
| 1383 | # Exit: 1 to signal caller should return, 0 otherwise. | |||||
| 1384 | # Side Effects: Mutates slanguage, rlanguage, slanguage_code_alpha2. | |||||
| 1385 | sub _resolve_base_match | |||||
| 1386 | { | |||||
| 1387 | 133 | 291 | my ($self, $l, $requested_sublanguage, $header) = @_; | |||
| 1388 | ||||||
| 1389 | 133 | 197 | $self->{_slanguage} = $self->_code2language($l); | |||
| 1390 | 133 | 348092 | return 0 unless $self->{_slanguage}; | |||
| 1391 | ||||||
| 1392 | 128 | 224 | $self->_debug("_slanguage: $self->{_slanguage}"); | |||
| 1393 | 128 | 3790 | $self->{_slanguage_code_alpha2} = $l; | |||
| 1394 | 128 | 123 | $self->{_rlanguage} = $self->{_slanguage}; | |||
| 1395 | ||||||
| 1396 | # Attempt to name the sublanguage the client actually asked for | |||||
| 1397 | 128 | 82 | my $sl; | |||
| 1398 | 128 | 248 | if($header =~ /..-(..)$/) { | |||
| 1399 | 9 | 14 | $self->_debug($1); | |||
| 1400 | 9 | 218 | $sl = $self->_code2country($1); | |||
| 1401 | 9 | 33 | $requested_sublanguage //= $1; | |||
| 1402 | } elsif($header =~ /..-([a-z]{2,3})$/i) { | |||||
| 1403 | 1 | 2 | if($_locale_object_db_ok // 1) { | |||
| 1404 | 1 1 | 1 3 | eval { $sl = Locale::Object::Country->new(code_alpha3 => $1) }; | |||
| 1405 | 1 | 260 | if($@) { | |||
| 1406 | 0 | 0 | $_locale_object_db_ok = 0 if $@ =~ /database was not in/; | |||
| 1407 | 0 | 0 | $self->_info($@); | |||
| 1408 | } else { | |||||
| 1409 | 1 | 2 | $_locale_object_db_ok = 1; | |||
| 1410 | } | |||||
| 1411 | } | |||||
| 1412 | } | |||||
| 1413 | ||||||
| 1414 | 128 | 202 | if($sl) { | |||
| 1415 | 8 | 17 | $self->{_rlanguage} .= ' (' . $sl->name() . ')'; | |||
| 1416 | } elsif($requested_sublanguage) { | |||||
| 1417 | 5 | 10 | if(my $c = $self->_code2countryname($requested_sublanguage)) { | |||
| 1418 | 2 | 12 | $self->{_rlanguage} .= " ($c)"; | |||
| 1419 | } else { | |||||
| 1420 | 3 | 57 | $self->{_rlanguage} .= " (Unknown: $requested_sublanguage)"; | |||
| 1421 | } | |||||
| 1422 | } | |||||
| 1423 | 128 | 342 | return 1; | |||
| 1424 | } | |||||
| 1425 | ||||||
| 1426 | # ââ _resolve_sublanguage_match ââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1427 | # Purpose: Handle the case where the full xx-yy code matched in the | |||||
| 1428 | # supported list. Resolves the variety name and caches results. | |||||
| 1429 | # Entry: $l â full code e.g. 'en-gb'; $alpha2 â 'en'; $variety â 'gb'; | |||||
| 1430 | # $header â full Accept-Language value. | |||||
| 1431 | # Exit: 1 to signal caller should return, 0 otherwise. | |||||
| 1432 | # Side Effects: Mutates _slanguage, _rlanguage, _sublanguage and code fields; | |||||
| 1433 | # writes to cache. | |||||
| 1434 | sub _resolve_sublanguage_match | |||||
| 1435 | { | |||||
| 1436 | 81 | 247 | my ($self, $l, $alpha2, $variety, $header) = @_; | |||
| 1437 | ||||||
| 1438 | 81 | 109 | my $i18n = I18N::AcceptLanguage->new(strict => 1); | |||
| 1439 | 81 | 995 | my $accepts = $i18n->accepts($l, $self->{_supported}); | |||
| 1440 | 81 | 2880 | $self->_debug('accepts = ', $accepts // 'undef'); | |||
| 1441 | ||||||
| 1442 | # $l is an entry of the supported list with a hyphen (e.g. 'en-gb'), so a | |||||
| 1443 | # strict match always gives back that same hyphenated entry. (An earlier | |||||
| 1444 | # branch for a hyphen-less answer could never run, and was removed.) | |||||
| 1445 | 81 | 2055 | delete $self->{_slanguage} if $accepts; | |||
| 1446 | ||||||
| 1447 | # Accepts returned something but we couldn't resolve a language name â | |||||
| 1448 | # try harder using the variety code directly | |||||
| 1449 | 81 | 118 | $self->{_rlanguage} = $self->_code2language($alpha2); | |||
| 1450 | 81 | 351681 | $self->_debug("_rlanguage: $self->{_rlanguage}"); | |||
| 1451 | ||||||
| 1452 | 81 | 2127 | return 0 unless $accepts; | |||
| 1453 | ||||||
| 1454 | 80 | 115 | $self->_debug("http_accept_language = $header"); | |||
| 1455 | 80 | 1952 | $l =~ /(..)-(..)/; | |||
| 1456 | 80 | 79 | $variety = lc($2); | |||
| 1457 | ||||||
| 1458 | # Skip numeric/region codes like en-029 | |||||
| 1459 | 80 | 292 | if(($variety =~ /[a-z]{2,3}/) && !defined($self->{_sublanguage})) { | |||
| 1460 | 80 | 139 | $self->_get_closest($alpha2, $alpha2); | |||
| 1461 | 80 | 107 | $self->_debug("Find the country code for $variety"); | |||
| 1462 | ||||||
| 1463 | 80 | 1864 | my ($from_cache, $language_name, $db_error); | |||
| 1464 | 80 | 126 | if($self->{_cache}) { | |||
| 1465 | 22 | 32 | $from_cache = $self->_cache_get_valid($CACHE_NS . "variety:$variety", $NAME_CODE_RE); | |||
| 1466 | } | |||||
| 1467 | ||||||
| 1468 | 80 | 191 | if(defined($from_cache)) { | |||
| 1469 | 6 | 11 | $self->_debug("$variety is in cache as $from_cache"); | |||
| 1470 | # Cache stores "countryname=langcode" (e.g. "United Kingdom=en"). | |||||
| 1471 | # Splitting on = gives the country name as the first field. | |||||
| 1472 | 6 | 84 | ($language_name) = split(/=/, $from_cache); | |||
| 1473 | # A poisoned entry such as "=en" has an empty name; treat it as a miss | |||||
| 1474 | 6 | 15 | undef $language_name unless defined($language_name) && length($language_name); | |||
| 1475 | } elsif($_locale_object_db_ok // 1) { | |||||
| 1476 | # Locale::Object's SQLite database is absent on some Windows | |||||
| 1477 | # installations; the sentinel avoids repeated failed new() calls. | |||||
| 1478 | # Keep the error in a lexical: $@ itself may still hold an | |||||
| 1479 | # unrelated error from the caller when this branch is skipped. | |||||
| 1480 | 74 | 57 | eval { | |||
| 1481 | 74 | 272 | my $db = Locale::Object::DB->new(); | |||
| 1482 | 74 74 | 89784 139 | my @results = @{$db->lookup( | |||
| 1483 | table => 'country', | |||||
| 1484 | result_column => 'name', | |||||
| 1485 | search_column => 'code_alpha2', | |||||
| 1486 | value => $variety | |||||
| 1487 | )}; | |||||
| 1488 | 74 | 9975 | $_locale_object_db_ok = 1; | |||
| 1489 | 74 | 98 | if(defined($results[0])) { | |||
| 1490 | 73 | 114 | $language_name = $self->_code2countryname($variety); | |||
| 1491 | } else { | |||||
| 1492 | 1 | 1 | $self->_debug("Can't find the country code for $variety in Locale::Object::DB"); | |||
| 1493 | } | |||||
| 1494 | 74 | 385 | 1; | |||
| 1495 | } or $db_error = ($@ || 'unknown error'); | |||||
| 1496 | 74 | 99 | if($db_error) { | |||
| 1497 | 0 | 0 | $_locale_object_db_ok = 0 | |||
| 1498 | if $db_error =~ /database was not in/; | |||||
| 1499 | # fall through: $language_name stays undef, caught below | |||||
| 1500 | } | |||||
| 1501 | } | |||||
| 1502 | ||||||
| 1503 | 80 | 178 | if($db_error || !defined($language_name)) { | |||
| 1504 | 1 | 2 | $self->_warn({ warning => $db_error }) if $db_error; | |||
| 1505 | # Locale::Object DB may be absent (common on Windows CI); fall back to | |||||
| 1506 | # the short-name table / Locale::Codes before giving up. | |||||
| 1507 | 1 | 2 | $language_name = $self->_country_short_name($variety); | |||
| 1508 | } | |||||
| 1509 | 80 | 121 | if(!defined($language_name)) { | |||
| 1510 | 1 | 1 | $self->_debug(__PACKAGE__, ': ', __LINE__, ': setting sublanguage to Unknown'); | |||
| 1511 | 1 | 32 | $self->{_sublanguage} = 'Unknown'; | |||
| 1512 | 1 | 3 | $self->_warn({ warning => "Can't determine values for $header" }); | |||
| 1513 | } else { | |||||
| 1514 | 79 | 94 | $self->{_sublanguage} = $language_name; | |||
| 1515 | 79 | 119 | $self->_debug('variety name ', $self->{_sublanguage}); | |||
| 1516 | 79 | 2191 | if($self->{_cache} && !defined($from_cache)) { | |||
| 1517 | # Store "countryname=langcode" so future cache hits return the country | |||||
| 1518 | # name in the first field. Previously this stored the language name | |||||
| 1519 | # ("English=en" for en-gb) which was wrong â the cache-hit branch | |||||
| 1520 | # split on = and used the first field as the sublanguage (country) name. | |||||
| 1521 | 16 | 30 | $self->_debug("Set variety:$variety to $language_name=$self->{_slanguage_code_alpha2}"); | |||
| 1522 | 16 | 377 | $self->_cache_call($self->{_cache}, 'set', | |||
| 1523 | $CACHE_NS . "variety:$variety", | |||||
| 1524 | "$language_name=$self->{_slanguage_code_alpha2}", | |||||
| 1525 | $CACHE_TTL_LONG | |||||
| 1526 | ); | |||||
| 1527 | } | |||||
| 1528 | } | |||||
| 1529 | } | |||||
| 1530 | ||||||
| 1531 | 80 | 101 | if(defined($self->{_sublanguage})) { | |||
| 1532 | 80 | 128 | $self->{_rlanguage} = "$self->{_slanguage} ($self->{_sublanguage})"; | |||
| 1533 | 80 | 86 | $self->{_sublanguage_code_alpha2} = $variety; | |||
| 1534 | 80 | 2311 | return 1; | |||
| 1535 | } | |||||
| 1536 | 0 | 0 | return 0; | |||
| 1537 | } | |||||
| 1538 | ||||||
| 1539 | # ââ _find_language_from_ip ââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1540 | # Purpose: Fall back to the visitor's IP country when the Accept-Language | |||||
| 1541 | # header produced no usable match. Looks up the official language | |||||
| 1542 | # of the country and checks it against the supported list. | |||||
| 1543 | # Entry: $http_accept_language â may be undef if no header was present. | |||||
| 1544 | # Exit: Mutates _slanguage, _rlanguage via _get_closest if a match found. | |||||
| 1545 | # Side Effects: Calls country(); may write to cache. | |||||
| 1546 | sub _find_language_from_ip | |||||
| 1547 | { | |||||
| 1548 | 103 | 812 | my ($self, $http_accept_language) = @_; | |||
| 1549 | ||||||
| 1550 | 103 | 196 | my $country = $self->country(); | |||
| 1551 | ||||||
| 1552 | # If country() returned nothing, try to derive from the LANG env var | |||||
| 1553 | 103 | 175 | if(!defined($country) && (my $c = $self->_what_language())) { | |||
| 1554 | 29 | 77 | if($c =~ /^(..)_(..)/) { | |||
| 1555 | 3 | 4 | $country = $2; | |||
| 1556 | } elsif($c =~ /^(..)$/) { | |||||
| 1557 | 16 | 16 | $country = $1; | |||
| 1558 | } | |||||
| 1559 | } | |||||
| 1560 | 103 | 164 | return unless defined $country; | |||
| 1561 | ||||||
| 1562 | 62 | 94 | $self->_debug("country: $country"); | |||
| 1563 | ||||||
| 1564 | 62 | 628 | my ($language_name, $language_code2, $from_cache); | |||
| 1565 | 62 | 148 | if($self->{_cache}) { | |||
| 1566 | 6 | 7 | $from_cache = $self->_cache_get_valid($CACHE_NS . 'language_name:' . $country, $NAME_CODE_RE); | |||
| 1567 | } | |||||
| 1568 | ||||||
| 1569 | 62 | 163 | if($from_cache) { | |||
| 1570 | 2 | 3 | $self->_debug("$country is in cache as $from_cache"); | |||
| 1571 | 2 | 35 | ($language_name, $language_code2) = split(/=/, $from_cache); | |||
| 1572 | } else { | |||||
| 1573 | 60 | 208 | my $l = $self->_code2country(uc($country)); | |||
| 1574 | 60 | 188 | if($l) { | |||
| 1575 | 51 | 136 | $l = ($l->languages_official)[0]; | |||
| 1576 | 51 | 21264 | if(defined $l) { | |||
| 1577 | 50 | 88 | $language_name = $l->name; | |||
| 1578 | 50 | 192 | $language_code2 = $l->code_alpha2; | |||
| 1579 | 50 | 259 | $self->_debug("Official language: $language_name") if $language_name; | |||
| 1580 | } | |||||
| 1581 | } | |||||
| 1582 | } | |||||
| 1583 | ||||||
| 1584 | # REMOTE_ADDR is absent when the country came from LANG (command-line use) | |||||
| 1585 | 62 | 675 | my $ip = $ENV{'REMOTE_ADDR'} // '(none)'; | |||
| 1586 | 62 | 99 | return unless $language_name; | |||
| 1587 | ||||||
| 1588 | 52 | 187 | if((!defined($self->{_rlanguage})) || ($self->{_rlanguage} eq 'Unknown')) { | |||
| 1589 | 44 | 64 | $self->{_rlanguage} = $language_name; | |||
| 1590 | } | |||||
| 1591 | ||||||
| 1592 | 52 | 166 | unless((exists $self->{_slanguage}) && ($self->{_slanguage} ne 'Unknown')) { | |||
| 1593 | 50 | 45 | my $code; | |||
| 1594 | ||||||
| 1595 | 50 | 176 | if($language_name && $language_code2 && !defined($http_accept_language)) { | |||
| 1596 | # Fast-path for search engines that hit with no Accept-Language | |||||
| 1597 | 40 | 71 | $self->_debug("Fast assign to $language_code2"); | |||
| 1598 | 40 | 166 | $code = $language_code2; | |||
| 1599 | } else { | |||||
| 1600 | 10 | 16 | $self->_debug("Call language2code on $self->{_rlanguage}"); | |||
| 1601 | 10 | 305 | $code = Locale::Language::language2code($self->{_rlanguage}); | |||
| 1602 | ||||||
| 1603 | 10 | 88773 | unless($code) { | |||
| 1604 | 1 | 4 | if($http_accept_language && ($http_accept_language ne $self->{_rlanguage})) { | |||
| 1605 | 1 | 2 | $self->_debug("Call language2code on $http_accept_language"); | |||
| 1606 | 1 | 3 | $code = Locale::Language::language2code($http_accept_language); | |||
| 1607 | } | |||||
| 1608 | 1 | 14 | unless($code) { | |||
| 1609 | # Norwegian (Nynorsk) â strip the parenthetical qualifier | |||||
| 1610 | 1 | 2 | if($self->{_rlanguage} =~ /(.+)\s\(.+/) { | |||
| 1611 | 0 | 0 | if((!defined($http_accept_language)) || ($1 ne $self->{_rlanguage})) { | |||
| 1612 | 0 | 0 | $self->_debug("Call language2code on $1"); | |||
| 1613 | 0 | 0 | $code = Locale::Language::language2code($1); | |||
| 1614 | } | |||||
| 1615 | } | |||||
| 1616 | 1 | 2 | unless($code) { | |||
| 1617 | 1 | 3 | $self->_warn({ | |||
| 1618 | warning => "Can't determine code from IP $ip for requested language $self->{_rlanguage}" | |||||
| 1619 | }); | |||||
| 1620 | } | |||||
| 1621 | } | |||||
| 1622 | } | |||||
| 1623 | } | |||||
| 1624 | ||||||
| 1625 | 50 | 81 | if($code) { | |||
| 1626 | 49 | 136 | $self->_get_closest($code, $language_code2); | |||
| 1627 | 49 | 176 | unless($self->{_slanguage}) { | |||
| 1628 | $self->_warn({ | |||||
| 1629 | warning => "Couldn't determine closest language for $language_name in " | |||||
| 1630 | 1 1 | 1 3 | . join(', ', @{$self->{_supported}}) | |||
| 1631 | }); | |||||
| 1632 | } else { | |||||
| 1633 | 48 | 196 | $self->_debug("language set to $self->{_slanguage}, code set to $code"); | |||
| 1634 | } | |||||
| 1635 | } | |||||
| 1636 | } | |||||
| 1637 | ||||||
| 1638 | 52 | 835 | if(!defined($self->{_slanguage_code_alpha2})) { | |||
| 1639 | 11 | 12 | $self->_debug("Can't determine slanguage_code_alpha2"); | |||
| 1640 | } elsif(!defined($from_cache) && $self->{_cache} && defined($self->{_slanguage_code_alpha2})) { | |||||
| 1641 | 3 | 8 | $self->_debug("Set $country to $language_name=$self->{_slanguage_code_alpha2}"); | |||
| 1642 | 3 | 64 | $self->_cache_call($self->{_cache}, 'set', | |||
| 1643 | $CACHE_NS . 'language_name:' . $country, | |||||
| 1644 | "$language_name=$self->{_slanguage_code_alpha2}", | |||||
| 1645 | $CACHE_TTL_LONG | |||||
| 1646 | ); | |||||
| 1647 | } | |||||
| 1648 | } | |||||
| 1649 | ||||||
| 1650 | # ââ _get_closest âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1651 | # Purpose: If $language_string matches the base language of any supported | |||||
| 1652 | # entry, set _slanguage and _slanguage_code_alpha2. | |||||
| 1653 | # Entry: $language_string â base code e.g. 'en'; $alpha2 â same or variant. | |||||
| 1654 | # Exit: Mutates _slanguage and _slanguage_code_alpha2 on match. | |||||
| 1655 | sub _get_closest | |||||
| 1656 | { | |||||
| 1657 | 135 | 208 | my ($self, $language_string, $alpha2) = @_; | |||
| 1658 | ||||||
| 1659 | # Map each supported entry to its base language code | |||||
| 1660 | my %base_languages = | |||||
| 1661 | 135 341 135 | 118 685 190 | map { /^(.+)-/ ? ($1 => $_) : ($_ => $_) } @{$self->{_supported}}; | |||
| 1662 | ||||||
| 1663 | 135 | 272 | if(exists $base_languages{$language_string}) { | |||
| 1664 | 124 | 151 | $self->{_slanguage} = $self->{_rlanguage}; | |||
| 1665 | 124 | 273 | $self->{_slanguage_code_alpha2} = $alpha2; | |||
| 1666 | } | |||||
| 1667 | } | |||||
| 1668 | ||||||
| 1669 | # ââ _what_language ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 1670 | # Purpose: Return the raw (validated, untainted) Accept-Language string, | |||||
| 1671 | # consulting in priority order: cached value, CGI lang= param, | |||||
| 1672 | # HTTP_ACCEPT_LANGUAGE env var, LANG env var (local/debug mode). | |||||
| 1673 | # Entry: May be called as a class method (no $self->{...} access) or | |||||
| 1674 | # as an object method. | |||||
| 1675 | # Exit: A validated language string, or undef if nothing available. | |||||
| 1676 | # Side Effects: Caches result in $self->{_what_language} on object calls. | |||||
| 1677 | sub _what_language { | |||||
| 1678 | 562 | 3591 | my $self = $_[0]; | |||
| 1679 | ||||||
| 1680 | 562 | 547 | if(ref($self)) { | |||
| 1681 | 394 | 398 | $self->_trace('Entered _what_language'); | |||
| 1682 | 394 | 10141 | if(defined($self->{_what_language})) { | |||
| 1683 | 29 | 42 | $self->_trace('_what_language: returning cached value: ', $self->{_what_language}); | |||
| 1684 | 29 | 860 | return $self->{_what_language}; | |||
| 1685 | } | |||||
| 1686 | 365 | 476 | if(my $info = $self->{_info}) { | |||
| 1687 | 4 | 12 | if(my $rc = _info_lang($info, $self)) { | |||
| 1688 | 2 | 13 | $self->_trace("_what_language set language to $rc from the lang argument"); | |||
| 1689 | 2 | 61 | return $self->{_what_language} = $rc; | |||
| 1690 | } | |||||
| 1691 | } | |||||
| 1692 | } | |||||
| 1693 | ||||||
| 1694 | 531 | 709 | if(my $raw_lang = $ENV{'HTTP_ACCEPT_LANGUAGE'}) { | |||
| 1695 | # Validate and untaint â RFC 7231 §5.3.5 character set plus * wildcard. | |||||
| 1696 | # Spaces and tabs only, not \s: CR and LF would carry header, log-line | |||||
| 1697 | # and Memcached-command injection into the cache key and messages. | |||||
| 1698 | 418 | 547 | if($raw_lang =~ $ACCEPT_LANG_RE) { | |||
| 1699 | 367 | 1380 | my $rc = $1; # untainted | |||
| 1700 | 367 | 376 | if(ref($self)) { | |||
| 1701 | 244 | 435 | return $self->{_what_language} = $rc; | |||
| 1702 | } | |||||
| 1703 | 123 | 256 | return $rc; | |||
| 1704 | } elsif(ref($self)) { | |||||
| 1705 | 51 | 239 | $self->_warn({ warning => 'HTTP_ACCEPT_LANGUAGE contains invalid characters; ignoring' }); | |||
| 1706 | } | |||||
| 1707 | } | |||||
| 1708 | ||||||
| 1709 | 164 | 4924 | if(defined($ENV{'LANG'})) { | |||
| 1710 | # Running locally (debug mode) â derive from system locale. | |||||
| 1711 | # Apply the same untainting discipline as HTTP_ACCEPT_LANGUAGE: only | |||||
| 1712 | # alphanumeric, hyphen, underscore, and dot are legitimate in a POSIX | |||||
| 1713 | # locale name (e.g. "en_US.UTF-8", "de_DE", "ja"). Anything else is | |||||
| 1714 | # either malformed or an injection attempt; discard it silently. | |||||
| 1715 | 19 | 42 | if($ENV{'LANG'} =~ /^([A-Za-z0-9_.\-]{1,$ACCEPT_LANG_MAX})$/a) { | |||
| 1716 | 8 | 141 | my $rc = $1; # untainted | |||
| 1717 | 8 | 16 | if(ref($self)) { | |||
| 1718 | 8 | 19 | return $self->{_what_language} = $rc; | |||
| 1719 | } | |||||
| 1720 | 0 | 0 | return $rc; | |||
| 1721 | } elsif(ref($self)) { | |||||
| 1722 | 11 | 153 | $self->_warn({ warning => 'LANG contains invalid characters; ignoring' }); | |||
| 1723 | } | |||||
| 1724 | } | |||||
| 1725 | 156 | 2694 | return; | |||
| 1726 | } | |||||
| 1727 | ||||||
| 1728 - 1827 | =head2 country
Returns the two-letter country code (ISO 3166-1) of the visitor,
in lower case, for example C<'us'>, C<'gb'> or C<'fr'>.
Returns C<undef> when the country cannot be found, for example when
C<REMOTE_ADDR> is not set, is not a valid IP address, or is a private
(C<192.168.1.1>) or loopback (C<127.0.0.1>) address.
In one special case it returns the string C<'Unknown'>:
when a source says the address is in the European Union (C<EU>),
which is not a country.
Two answers are corrected: C<hk> (Hong Kong) is returned as C<cn>, and a Whois
record that says C<US> with the state C<PR> is returned as C<pr> (Puerto Rico
has its own country code; RT#131347).
See L</Finding the country> for the order in which the sources are tried.
If you have none of L<IP::Country>, L<Geo::IP> or L<Geo::IPfree> installed,
every look-up goes over the network, so please use a C<cache> (see L</new>).
The answer is remembered, so a second call on the same object is fast.
Note that as of October 2026 geoplugin.net, one of the remote
fallbacks, no longer has a free tier: it answers with an HTTP 403 and
a "please upgrade to a paid plan" message, which contains no country
code, so the lookup falls through to Whois.
The geoplugin.net code is kept in case that changes.
Legacy MaxMind F<GeoIP.dat> databases, as used by L<Geo::IP>, are no
longer updated (Debian's C<geoip-database> package is frozen at
2019-12-24), so they can give the wrong country for addresses that
have been reallocated since then.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
matches => qr/^(?:[a-z][a-z]|Unknown)$/,
optional => 1,
}
=head3 EXAMPLE
# Behind Apache mod_geoip: no IP look-up is needed
local $ENV{GEOIP_COUNTRY_CODE} = 'DE';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->country(); # "de"
# From the IP address (the answer depends on your geo database)
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->country() // 'not known'; # "us"
=head3 MESSAGES
These are warnings. C<country()> does not die.
"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"
"cache contains a numeric country: N"
"IP matches to a numeric country"
"geoplugin returned unparseable JSON: ..."
"Discarding malformed country code '...'"
"geoplugin lookup failed: ..."
"Cache get failed: ...", "Cache set failed: ...", "Cache remove failed: ..."
"Discarding malformed cache entry for ..."
These are debug messages, sent only to the logger:
"Can't determine country from LAN connection X"
"Can't determine country from loopback connection X"
=head3 PSEUDOCODE
1. Return the remembered answer if there is one
2. Check GEOIP_COUNTRY_CODE env var (mod_geoip); validate /^[A-Z]{2}\z/
3. Check HTTP_CF_IPCOUNTRY (Cloudflare); skip 'XX'; validate /^[A-Z]{2}\z/
4. Untaint and validate REMOTE_ADDR; return undef if absent or invalid;
change ::ffff:a.b.c.d into a.b.c.d
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;
no free tier as of October 2026, so normally yields nothing)
11. Last resort: Net::Whois::IP then Net::Whois::IANA
12. Sanitise: discard numeric, normalise HK->CN, handle EU special case,
discard anything that is not two lower-case letters
13. Store in CHI cache; return result
=cut | |||||
| 1828 | ||||||
| 1829 | sub country { | |||||
| 1830 | 369 | 8601 | my $self = shift; | |||
| 1831 | 369 | 1056 | local ($@, $!); # evals and lazy requires below must not leak to the caller | |||
| 1832 | ||||||
| 1833 | 369 | 502 | $self->_trace(__PACKAGE__, ': Entered country()'); | |||
| 1834 | ||||||
| 1835 | # Return cached result immediately if a previous call already resolved it. | |||||
| 1836 | # Note: undef results (private/loopback IPs) are NOT cached here because | |||||
| 1837 | # country() reads REMOTE_ADDR at call time, not construction time; caching | |||||
| 1838 | # undef would give wrong answers if REMOTE_ADDR changes between calls on | |||||
| 1839 | # the same object (the documented lazy-read design). See LIMITATIONS. | |||||
| 1840 | 369 | 8502 | if($self->{_country}) { | |||
| 1841 | 40 | 77 | $self->_trace('quick return: ', $self->{_country}); | |||
| 1842 | 40 | 392 | return $self->{_country}; | |||
| 1843 | } | |||||
| 1844 | ||||||
| 1845 | # mod_geoip: validate against ISO 3166-1 alpha-2 before trusting | |||||
| 1846 | 329 | 2465 | if(defined($ENV{'GEOIP_COUNTRY_CODE'})) { | |||
| 1847 | 53 | 110 | if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})\z/a) { | |||
| 1848 | 27 | 49 | $self->{_country} = lc($1); | |||
| 1849 | 27 | 273 | return $self->{_country}; | |||
| 1850 | } else { | |||||
| 1851 | 26 | 46 | $self->_warn({ warning => 'GEOIP_COUNTRY_CODE contains an invalid country code; ignoring' }); | |||
| 1852 | } | |||||
| 1853 | } | |||||
| 1854 | ||||||
| 1855 | # Cloudflare: 'XX' means Cloudflare couldn't determine country â skip it | |||||
| 1856 | 302 | 1327 | if(($ENV{'HTTP_CF_IPCOUNTRY'}) && ($ENV{'HTTP_CF_IPCOUNTRY'} ne 'XX')) { | |||
| 1857 | 15 | 35 | if($ENV{'HTTP_CF_IPCOUNTRY'} =~ /^([A-Z]{2})\z/a) { | |||
| 1858 | 5 | 11 | $self->{_country} = lc($1); | |||
| 1859 | 5 | 17 | return $self->{_country}; | |||
| 1860 | } else { | |||||
| 1861 | 10 | 20 | $self->_warn({ warning => 'HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring' }); | |||
| 1862 | } | |||||
| 1863 | } | |||||
| 1864 | ||||||
| 1865 | 297 | 289 | my $raw_ip = $ENV{'REMOTE_ADDR'}; | |||
| 1866 | 297 | 381 | return undef unless defined $raw_ip; | |||
| 1867 | ||||||
| 1868 | # Validate and untaint the IP address before passing to any geo module | |||||
| 1869 | 246 | 277 | my $ip = _untaint_ip($raw_ip); | |||
| 1870 | 246 | 1339 | unless(defined($ip)) { | |||
| 1871 | 24 | 53 | $self->_warn({ warning => "$raw_ip isn't a valid IP address" }); | |||
| 1872 | 24 | 1231 | return undef; | |||
| 1873 | } | |||||
| 1874 | ||||||
| 1875 | # Data::Validate::IP depends on NetAddr::IP, which fails to build on Windows | |||||
| 1876 | # (NetAddr::IP::UtilPP::mask4to6 bad-argument error). Try to load it once; | |||||
| 1877 | # on failure install pure-Perl aliases for the four bare function names used | |||||
| 1878 | # below so the rest of the function is unchanged on both platforms. | |||||
| 1879 | # Import only those four: a plain import() installs all 29 of the module's | |||||
| 1880 | # exports here, where they would become methods of every object. | |||||
| 1881 | 222 | 266 | if(!defined($_have_dvip)) { | |||
| 1882 | 44 | 79 | local $SIG{__DIE__}; | |||
| 1883 | 44 44 0 0 | 40 735 0 0 | if(eval { require Data::Validate::IP; Data::Validate::IP->import(qw(is_ipv4 is_ipv6 is_private_ip is_loopback_ip)); 1 }) { | |||
| 1884 | 0 | 0 | $_have_dvip = 1; | |||
| 1885 | } else { | |||||
| 1886 | 44 | 2861 | $_have_dvip = 0; | |||
| 1887 | 77 77 77 | 259868 107 242752 | no warnings 'redefine'; | |||
| 1888 | 44 | 92 | *CGI::Lingua::is_ipv4 = \&_is_ipv4; | |||
| 1889 | 44 | 60 | *CGI::Lingua::is_ipv6 = \&_is_ipv6; | |||
| 1890 | 44 | 42 | *CGI::Lingua::is_private_ip = \&_is_private_ip; | |||
| 1891 | 44 | 97 | *CGI::Lingua::is_loopback_ip = \&_is_loopback_ip; | |||
| 1892 | } | |||||
| 1893 | } | |||||
| 1894 | ||||||
| 1895 | 222 | 264 | if(!is_ipv4($ip)) { | |||
| 1896 | 17 | 36 | $self->_debug("$ip isn't IPv4. Is it IPv6?"); | |||
| 1897 | 17 | 350 | if($ip eq '::1') { | |||
| 1898 | 4 | 6 | $ip = '127.0.0.1'; # normalise loopback | |||
| 1899 | } elsif($ip =~ /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i) { | |||||
| 1900 | 9 | 11 | $ip = $1; # normalise IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 | |||
| 1901 | # \d{1,3} matches 0-999; validate range before geo lookups because | |||||
| 1902 | # some inet_aton implementations wrap out-of-range octets modulo 256, | |||||
| 1903 | # turning 999.999.999.999 into a real routable address. | |||||
| 1904 | 9 | 20 | unless(is_ipv4($ip)) { | |||
| 1905 | 3 | 8 | $self->_warn({ warning => "$ip isn't a valid IP address" }); | |||
| 1906 | 3 | 478 | return undef; | |||
| 1907 | } | |||||
| 1908 | } elsif(!is_ipv6($ip)) { | |||||
| 1909 | 1 | 3 | $self->_warn({ warning => "$ip isn't a valid IP address" }); | |||
| 1910 | 1 | 3 | return undef; | |||
| 1911 | } | |||||
| 1912 | } | |||||
| 1913 | 218 | 1792 | if(is_private_ip($ip)) { | |||
| 1914 | 8 | 23 | $self->_debug("Can't determine country from LAN connection $ip"); | |||
| 1915 | 8 | 148 | return undef; | |||
| 1916 | } | |||||
| 1917 | 210 | 239 | if(is_loopback_ip($ip)) { | |||
| 1918 | 72 | 116 | $self->_debug("Can't determine country from loopback connection $ip"); | |||
| 1919 | 72 | 2325 | return undef; | |||
| 1920 | } | |||||
| 1921 | ||||||
| 1922 | # Cache look-up â skip for LAN/loopback (already returned above) | |||||
| 1923 | 138 | 261 | if($self->{_cache}) { | |||
| 1924 | 21 | 43 | $self->{_country} = $self->_cache_call($self->{_cache}, 'get', $CACHE_NS . "country:$ip"); | |||
| 1925 | 21 | 43 | if(defined($self->{_country})) { | |||
| 1926 | 11 | 63 | if(!ref($self->{_country}) && ($self->{_country} !~ /\D/)) { | |||
| 1927 | 8 | 26 | $self->_warn({ warning => 'cache contains a numeric country: ' . $self->{_country} }); | |||
| 1928 | 8 | 520 | $self->_cache_call($self->{_cache}, 'remove', $CACHE_NS . "country:$ip"); | |||
| 1929 | 8 | 10 | delete $self->{_country}; | |||
| 1930 | } elsif(ref($self->{_country}) || ($self->{_country} !~ $COUNTRY_RE)) { | |||||
| 1931 | # Poisoned entry ("gb<script>", a hashref): same checks as a | |||||
| 1932 | # fresh look-up, or the cache would bypass them | |||||
| 1933 | 2 | 14 | $self->_warn({ warning => "Discarding malformed cache entry for ${CACHE_NS}country:$ip" }); | |||
| 1934 | 2 | 24 | $self->_cache_call($self->{_cache}, 'remove', $CACHE_NS . "country:$ip"); | |||
| 1935 | 2 | 4 | delete $self->{_country}; | |||
| 1936 | } else { | |||||
| 1937 | 1 | 5 | $self->_debug("Get $ip from cache = $self->{_country}"); | |||
| 1938 | 1 | 33 | return $self->{_country}; | |||
| 1939 | } | |||||
| 1940 | } | |||||
| 1941 | 20 | 37 | $self->_debug("$ip isn't in the cache"); | |||
| 1942 | } | |||||
| 1943 | ||||||
| 1944 | # Try IP::Country first (fastest, local database) | |||||
| 1945 | 137 | 646 | if($self->{_have_ipcountry} == $GEO_UNKNOWN) { | |||
| 1946 | 45 45 | 144 220 | if(eval { require IP::Country::Fast }) { | |||
| 1947 | # Require the concrete class directly; IP::Country->import() is not needed | |||||
| 1948 | # because IP::Country::Fast->new() is a fully-qualified class method call. | |||||
| 1949 | 0 | 0 | $self->{_have_ipcountry} = $GEO_PRESENT; | |||
| 1950 | 0 | 0 | $self->{_ipcountry} = IP::Country::Fast->new(); | |||
| 1951 | } else { | |||||
| 1952 | 45 | 4154 | $self->{_have_ipcountry} = $GEO_ABSENT; | |||
| 1953 | } | |||||
| 1954 | } | |||||
| 1955 | 137 | 990 | $self->_debug("have_ipcountry $self->{_have_ipcountry}"); | |||
| 1956 | ||||||
| 1957 | 137 | 1936 | if($self->{_have_ipcountry}) { | |||
| 1958 | 32 | 45 | $self->{_country} = $self->{_ipcountry}->inet_atocc($ip); | |||
| 1959 | 32 | 73 | if($self->{_country}) { | |||
| 1960 | 27 | 35 | $self->{_country} = lc($self->{_country}); | |||
| 1961 | } elsif(is_ipv4($ip)) { | |||||
| 1962 | 5 | 6 | $self->_debug("$ip is not known by IP::Country"); | |||
| 1963 | } | |||||
| 1964 | } | |||||
| 1965 | ||||||
| 1966 | # Try Geo::IP if IP::Country gave nothing | |||||
| 1967 | 137 | 244 | unless(defined($self->{_country})) { | |||
| 1968 | 106 | 110 | if($self->{_have_geoip} == $GEO_UNKNOWN) { | |||
| 1969 | 46 | 168 | $self->_load_geoip(); | |||
| 1970 | } | |||||
| 1971 | 106 | 210 | if($self->{_have_geoip} == $GEO_PRESENT) { | |||
| 1972 | 1 | 3 | $self->{_country} = $self->{_geoip}->country_code_by_addr($ip); | |||
| 1973 | } | |||||
| 1974 | ||||||
| 1975 | # Geo::IPfree has a known-broken entry for $BROKEN_GEOIPFREE | |||||
| 1976 | 106 | 455 | if(!defined($self->{_country}) && ($ip ne $BROKEN_GEOIPFREE)) { | |||
| 1977 | 105 | 292 | if($self->{_have_geoipfree} == $GEO_UNKNOWN) { | |||
| 1978 | 45 45 | 99 224 | eval { require Geo::IPfree }; | |||
| 1979 | 45 | 3323 | unless($@) { | |||
| 1980 | # No ->import(): Geo::IPfree uses only OO (->LookUp); the old | |||||
| 1981 | # Geo::IPfree::IP->import() call was a wrong package and could | |||||
| 1982 | # clobber Test::Mockingbird mocks on some versions. | |||||
| 1983 | 0 | 0 | $self->{_have_geoipfree} = $GEO_PRESENT; | |||
| 1984 | 0 | 0 | $self->{_geoipfree} = Geo::IPfree->new(); | |||
| 1985 | } else { | |||||
| 1986 | 45 | 57 | $self->{_have_geoipfree} = $GEO_ABSENT; | |||
| 1987 | } | |||||
| 1988 | } | |||||
| 1989 | 105 | 249 | if($self->{_have_geoipfree} == $GEO_PRESENT) { | |||
| 1990 | 1 | 4 | if(my $country = ($self->{_geoipfree}->LookUp($ip))[0]) { | |||
| 1991 | 1 | 4 | $self->{_country} = lc($country); | |||
| 1992 | } | |||||
| 1993 | } | |||||
| 1994 | } | |||||
| 1995 | } | |||||
| 1996 | ||||||
| 1997 | # 'eu' is not a real country â discard | |||||
| 1998 | 137 | 397 | if($self->{_country} && ($self->{_country} eq 'eu')) { | |||
| 1999 | 2 | 2 | delete $self->{_country}; | |||
| 2000 | } | |||||
| 2001 | ||||||
| 2002 | # Remote JSON lookup via geoplugin | |||||
| 2003 | 137 | 211 | if((!$self->{_country}) && | |||
| 2004 | 110 84 | 538 33639 | (eval { require LWP::Simple::WithCache; require JSON::Parse })) { | |||
| 2005 | 76 | 735 | $self->_debug("Look up $ip on geoplugin"); | |||
| 2006 | ||||||
| 2007 | # A timeout or connection error must not take down the request; | |||||
| 2008 | # fall through to Whois instead | |||||
| 2009 | 76 76 76 | 1115 108 131 | my $data = eval { local $SIG{__DIE__}; LWP::Simple::WithCache::get("https://www.geoplugin.net/json.gp?ip=$ip") }; | |||
| 2010 | 76 | 506846 | $self->_warn({ warning => "geoplugin lookup failed: $@" }) if $@; | |||
| 2011 | 76 | 95 | if($data) { | |||
| 2012 | 32 32 | 37 2594 | eval { $self->{_country} = JSON::Parse::parse_json($data)->{'geoplugin_countryCode'} }; | |||
| 2013 | 32 | 57 | $self->_warn({ warning => "geoplugin returned unparseable JSON: $@" }) if $@; | |||
| 2014 | } | |||||
| 2015 | ||||||
| 2016 | # Check the answer now rather than at the end: a junk value (an object, | |||||
| 2017 | # "GB<script>") is true, so it would stop the Whois fallback below and | |||||
| 2018 | # then be thrown away, leaving no country at all | |||||
| 2019 | 76 | 764 | my $v = $self->{_country}; | |||
| 2020 | 76 | 192 | if(defined($v) && (ref($v) || $v !~ /^[A-Za-z]{2}\z/)) { | |||
| 2021 | 10 | 89 | if(!ref($v) && length($v) && ($v !~ /\D/)) { | |||
| 2022 | 4 | 8 | $self->_warn({ warning => 'IP matches to a numeric country' }); | |||
| 2023 | } elsif(ref($v) || length($v)) { | |||||
| 2024 | 4 | 12 | $self->_warn({ warning => q{Discarding malformed country code '} . lc($v) . q{'} }); | |||
| 2025 | } | |||||
| 2026 | 10 | 302 | delete $self->{_country}; | |||
| 2027 | } | |||||
| 2028 | } | |||||
| 2029 | ||||||
| 2030 | # Last resort: Whois | |||||
| 2031 | 137 | 2308 | unless($self->{_country}) { | |||
| 2032 | 95 | 138 | $self->_resolve_country_via_whois($ip); | |||
| 2033 | } | |||||
| 2034 | ||||||
| 2035 | # Sanitise and normalise whatever we found | |||||
| 2036 | 137 | 240 | if($self->{_country}) { | |||
| 2037 | 82 | 178 | if($self->{_country} !~ /\D/) { | |||
| 2038 | 2 | 6 | $self->_warn({ warning => 'IP matches to a numeric country' }); | |||
| 2039 | 2 | 253 | delete $self->{_country}; | |||
| 2040 | } else { | |||||
| 2041 | 80 | 103 | $self->{_country} = lc($self->{_country}); | |||
| 2042 | ||||||
| 2043 | # Legacy mappings | |||||
| 2044 | 80 | 175 | if($self->{_country} eq 'hk') { | |||
| 2045 | 3 | 4 | $self->{_country} = 'cn'; # HK is no longer a separate country in Whois | |||
| 2046 | } elsif($self->{_country} eq 'eu') { | |||||
| 2047 | 7 | 8 | $self->_handle_eu_country($ip); | |||
| 2048 | } | |||||
| 2049 | ||||||
| 2050 | 80 | 645 | if($self->{_country} && ($self->{_country} !~ /\D/)) { | |||
| 2051 | 0 | 0 | $self->_warn({ warning => "cache contains a numeric country: $self->{_country}" }); | |||
| 2052 | 0 | 0 | delete $self->{_country}; | |||
| 2053 | } elsif($self->{_country} && | |||||
| 2054 | $self->{_country} ne 'Unknown' && | |||||
| 2055 | ($self->{_country} !~ /^[a-z]{2}$/)) { | |||||
| 2056 | # Reject anything that is not exactly 2 lowercase ASCII letters, | |||||
| 2057 | # unless it is the 'Unknown' sentinel written by _handle_eu_country | |||||
| 2058 | # for EU addresses that do not map to a specific country. | |||||
| 2059 | # Guards against Whois CRLF injection leftovers ("gbx-header: evil") | |||||
| 2060 | # and XSS payloads in JSON API responses ("gb<script>...</script>"). | |||||
| 2061 | 1 | 3 | $self->_warn({ warning => "Discarding malformed country code '$self->{_country}'" }); | |||
| 2062 | 1 | 2 | delete $self->{_country}; | |||
| 2063 | } elsif($self->{_country} && $self->{_cache}) { | |||||
| 2064 | 10 | 16 | $self->_debug("Set $ip to $self->{_country}"); | |||
| 2065 | $self->_cache_call($self->{_cache}, 'set', | |||||
| 2066 | $CACHE_NS . "country:$ip", | |||||
| 2067 | $self->{_country}, | |||||
| 2068 | 10 | 202 | $CACHE_TTL_SHORT | |||
| 2069 | ); | |||||
| 2070 | } | |||||
| 2071 | } | |||||
| 2072 | } | |||||
| 2073 | ||||||
| 2074 | 137 | 283 | return $self->{_country}; | |||
| 2075 | } | |||||
| 2076 | ||||||
| 2077 | # ââ _resolve_country_via_whois âââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2078 | # Purpose: Attempt Net::Whois::IP then Net::Whois::IANA as a last resort. | |||||
| 2079 | # Entry: $ip â validated, untainted IP string. | |||||
| 2080 | # Exit: Sets $self->{_country} if a result was found. | |||||
| 2081 | # Side Effects: Network I/O; logs debug messages. | |||||
| 2082 | sub _resolve_country_via_whois | |||||
| 2083 | { | |||||
| 2084 | 75 | 351 | my ($self, $ip) = @_; | |||
| 2085 | ||||||
| 2086 | 75 | 185 | $self->_debug("Look up $ip on Whois"); | |||
| 2087 | ||||||
| 2088 | 75 | 1342 | require Net::Whois::IP; | |||
| 2089 | # No ->import(): whoisip_query is called fully-qualified, so import is unneeded | |||||
| 2090 | # and on some versions reinstalls the real function, clobbering Test::Mockingbird mocks. | |||||
| 2091 | ||||||
| 2092 | 75 | 6853 | my $whois; | |||
| 2093 | 75 | 52 | eval { | |||
| 2094 | # Catch connection timeouts by converting Carp::carp into a die | |||||
| 2095 | 75 2 | 224 20 | local $SIG{__WARN__} = sub { die $_[0] }; | |||
| 2096 | 75 | 135 | $whois = Net::Whois::IP::whoisip_query($ip); | |||
| 2097 | }; | |||||
| 2098 | ||||||
| 2099 | 75 | 1133041 | unless($@ || !defined($whois) || (ref($whois) ne 'HASH')) { | |||
| 2100 | 54 | 77 | if(defined($whois->{Country})) { | |||
| 2101 | 51 | 81 | $self->{_country} = $whois->{Country}; | |||
| 2102 | } elsif(defined($whois->{country})) { | |||||
| 2103 | 3 | 4 | $self->{_country} = $whois->{country}; | |||
| 2104 | } | |||||
| 2105 | 54 | 78 | if($self->{_country}) { | |||
| 2106 | 54 | 181 | if($self->{_country} eq 'EU') { | |||
| 2107 | 7 | 8 | delete $self->{_country}; | |||
| 2108 | } elsif(($self->{_country} eq 'US') && defined($whois->{'StateProv'}) && ($whois->{'StateProv'} eq 'PR')) { | |||||
| 2109 | # RT#131347: Puerto Rico is not the US | |||||
| 2110 | 2 | 2 | $self->{_country} = 'pr'; | |||
| 2111 | } | |||||
| 2112 | } | |||||
| 2113 | } | |||||
| 2114 | ||||||
| 2115 | 75 | 88 | if($self->{_country}) { | |||
| 2116 | 47 | 102 | $self->_debug("Found $ip on Net::Whois::IP as ", $self->{_country}); | |||
| 2117 | 47 | 537 | $self->{_country} = _clean_country_code($self->{_country}); | |||
| 2118 | # _clean_country_code returns undef for malformed values (e.g. CRLF | |||||
| 2119 | # injection leftovers); if so, fall through to the IANA look-up. | |||||
| 2120 | 47 | 98 | return if defined $self->{_country}; | |||
| 2121 | 2 | 2 | delete $self->{_country}; | |||
| 2122 | } | |||||
| 2123 | ||||||
| 2124 | 30 | 63 | $self->_debug("Look up $ip on IANA"); | |||
| 2125 | ||||||
| 2126 | 30 | 691 | require Net::Whois::IANA; | |||
| 2127 | # No ->import(): Net::Whois::IANA->new() is a class method; import not needed. | |||||
| 2128 | ||||||
| 2129 | # The whole IANA exchange is in one eval: new(), the query and the parse | |||||
| 2130 | # of the answer can all die on a network or protocol error, and this is | |||||
| 2131 | # the last resort, so a failure simply means "no country" | |||||
| 2132 | 30 | 5151 | my $country; | |||
| 2133 | 30 | 21 | if(eval { | |||
| 2134 | 30 | 37 | local $SIG{__DIE__}; | |||
| 2135 | 30 | 58 | my $iana = Net::Whois::IANA->new(); | |||
| 2136 | 29 | 168 | $iana->whois_query(-ip => $ip); | |||
| 2137 | 28 | 89786 | $country = $iana->country(); | |||
| 2138 | 28 | 123 | 1; | |||
| 2139 | }) { | |||||
| 2140 | 28 | 34 | $self->{_country} = $country; | |||
| 2141 | 28 | 113 | $self->_debug("IANA reports $ip as ", $self->{_country}); | |||
| 2142 | } else { | |||||
| 2143 | 2 | 24 | $self->_debug("IANA look-up of $ip failed: $@"); | |||
| 2144 | } | |||||
| 2145 | ||||||
| 2146 | 30 | 411 | if($self->{_country}) { | |||
| 2147 | 16 | 28 | $self->{_country} = _clean_country_code($self->{_country}); | |||
| 2148 | 16 | 34 | delete $self->{_country} unless defined $self->{_country}; | |||
| 2149 | } | |||||
| 2150 | } | |||||
| 2151 | ||||||
| 2152 | # ââ _clean_country_code âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2153 | # Purpose: Strip carriage returns and trailing "#â¦" comments that some | |||||
| 2154 | # Whois servers append to their country field | |||||
| 2155 | # (e.g. "US\r", "GB # United Kingdom"). | |||||
| 2156 | # Entry: $raw â raw country string from a Whois response. | |||||
| 2157 | # Exit: Cleaned 2-char country code string. | |||||
| 2158 | sub _clean_country_code | |||||
| 2159 | { | |||||
| 2160 | 81 | 5463 | my ($raw) = @_; | |||
| 2161 | 81 | 136 | return unless defined($raw); | |||
| 2162 | 80 | 95 | $raw =~ s/[\r\n]//g; | |||
| 2163 | # Accept exactly 2 alpha chars, optionally followed by whitespace and a | |||||
| 2164 | # comment (e.g. "GB # United Kingdom"). Anything else (CRLF injection | |||||
| 2165 | # leftovers, embedded headers) returns undef so the caller can discard it | |||||
| 2166 | # rather than propagating a malformed string through the geo pipeline. | |||||
| 2167 | 80 | 262 | if($raw =~ /^([A-Za-z]{2})\s*(?:#.*)?$/) { | |||
| 2168 | 66 | 110 | return $1; | |||
| 2169 | } | |||||
| 2170 | 14 | 19 | return; | |||
| 2171 | } | |||||
| 2172 | ||||||
| 2173 | # ââ _in_baidu_subnet ââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2174 | # Purpose: Pure-Perl fallback check for the Baidu IPv4 subnet | |||||
| 2175 | # 185.10.104.0/22. Used when Net::Subnet is absent (e.g. on | |||||
| 2176 | # Windows where its Socket6 dependency fails to build). | |||||
| 2177 | # Entry: $ip â untainted IPv4 string. | |||||
| 2178 | # Exit: Returns true if $ip is within the /22 block, false otherwise. | |||||
| 2179 | sub _in_baidu_subnet | |||||
| 2180 | { | |||||
| 2181 | 33 | 6245 | my $ip = shift; | |||
| 2182 | 33 | 117 | return 0 unless defined($ip) && $ip =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/a; | |||
| 2183 | # pack('C') wraps octets above 255 (300 becomes 44), which would put a | |||||
| 2184 | # malformed address such as 185.10.104.300 inside the subnet | |||||
| 2185 | 28 112 | 28 99 | return 0 if grep { $_ > 255 } ($1, $2, $3, $4); | |||
| 2186 | # pack/unpack avoids signed-integer overflow on 32-bit Perl | |||||
| 2187 | 27 | 70 | my $n = unpack('N', pack('C4', $1, $2, $3, $4)); | |||
| 2188 | 27 | 28 | my $net = unpack('N', pack('C4', 185, 10, 104, 0)); | |||
| 2189 | 27 | 47 | return (($n & 0xFFFFFC00) == ($net & 0xFFFFFC00)); | |||
| 2190 | } | |||||
| 2191 | ||||||
| 2192 | # ââ _handle_eu_country ââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2193 | # Purpose: Resolve the ambiguous 'eu' country code. RT-86809 shows that | |||||
| 2194 | # Baidu reports itself as EU when it is actually in CN. All | |||||
| 2195 | # other 'eu' addresses are logged as Unknown. | |||||
| 2196 | # Entry: $ip â validated, untainted IP string. | |||||
| 2197 | # Exit: Sets $self->{_country} to 'cn' or 'Unknown'. | |||||
| 2198 | # Side Effects: Optionally loads Net::Subnet; writes info log entry. | |||||
| 2199 | sub _handle_eu_country | |||||
| 2200 | { | |||||
| 2201 | 11 | 24 | my ($self, $ip) = @_; | |||
| 2202 | ||||||
| 2203 | # Prefer Net::Subnet for correctness; fall back to the pure-Perl helper | |||||
| 2204 | # when it is absent (Socket6, its indirect dep, fails to build on Windows). | |||||
| 2205 | 11 | 16 | my $in_baidu; | |||
| 2206 | 11 11 0 0 | 8 366 0 0 | if(eval { require Net::Subnet; Net::Subnet->import(); 1 }) { | |||
| 2207 | 0 | 0 | $in_baidu = subnet_matcher($BAIDU_SUBNET)->($ip); | |||
| 2208 | } else { | |||||
| 2209 | 11 | 399 | $in_baidu = _in_baidu_subnet($ip); | |||
| 2210 | } | |||||
| 2211 | ||||||
| 2212 | 11 | 23 | if($in_baidu) { | |||
| 2213 | 5 | 33 | $self->{_country} = 'cn'; | |||
| 2214 | } else { | |||||
| 2215 | 6 | 14 | $self->_info("$ip has country of eu"); | |||
| 2216 | 6 | 174 | $self->{_country} = 'Unknown'; | |||
| 2217 | } | |||||
| 2218 | } | |||||
| 2219 | ||||||
| 2220 | # ââ _load_geoip âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2221 | # Purpose: Probe for the Geo::IP database file and the Geo::IP module; | |||||
| 2222 | # set _have_geoip and initialise _geoip on success. | |||||
| 2223 | # Entry: _have_geoip must be GEO_UNKNOWN. | |||||
| 2224 | # Exit: _have_geoip set to GEO_PRESENT or GEO_ABSENT. | |||||
| 2225 | # Side Effects: Requires Geo::IP; opens GeoIP.dat. | |||||
| 2226 | sub _load_geoip | |||||
| 2227 | { | |||||
| 2228 | 58 | 74 | my $self = shift; | |||
| 2229 | ||||||
| 2230 | # Check for the database file before even trying to load the module | |||||
| 2231 | # (avoids noisy errors on Windows â CPANTESTERS report 54117bd0) | |||||
| 2232 | 58 115 | 76 482 | my ($dat) = grep { -f $_ && -r _ } @GEOIP_DAT; | |||
| 2233 | 58 | 73 | unless(defined($dat)) { | |||
| 2234 | 57 | 73 | $self->{_have_geoip} = $GEO_ABSENT; | |||
| 2235 | 57 | 144 | return; | |||
| 2236 | } | |||||
| 2237 | ||||||
| 2238 | 1 1 1 0 | 1 2 4 0 | unless(eval { local $SIG{__DIE__}; require Geo::IP; 1 }) { | |||
| 2239 | 1 | 7 | $self->{_have_geoip} = $GEO_ABSENT; | |||
| 2240 | 1 | 4 | return; | |||
| 2241 | } | |||||
| 2242 | ||||||
| 2243 | # No ->import(): Geo::IP->open() is a class method. GEOIP_STANDARD = 0 | |||||
| 2244 | # (the constant cannot be used by name). A corrupt or truncated file makes | |||||
| 2245 | # open() die or return undef; treat that as no database, rather than let | |||||
| 2246 | # country() call a method on undef later. | |||||
| 2247 | 0 0 0 | 0 0 0 | my $geoip = eval { local $SIG{__DIE__}; Geo::IP->open($dat, 0) }; | |||
| 2248 | 0 | 0 | if(Scalar::Util::blessed($geoip)) { | |||
| 2249 | 0 | 0 | $self->{_have_geoip} = $GEO_PRESENT; | |||
| 2250 | 0 | 0 | $self->{_geoip} = $geoip; | |||
| 2251 | } else { | |||||
| 2252 | 0 | 0 | $self->_warn({ warning => "Can't open $dat with Geo::IP; not using it" }); | |||
| 2253 | 0 | 0 | $self->{_have_geoip} = $GEO_ABSENT; | |||
| 2254 | } | |||||
| 2255 | } | |||||
| 2256 | ||||||
| 2257 - 2317 | =head2 locale
Returns a L<Locale::Object::Country> object for the visitor's country.
You can use it to find, for example, the currency or the country's name.
HTTP does not send a browser's local settings (such as the currency or the
date format), so this is a B<best guess>. It is not always right.
It tries these, in order:
=over 4
=item 1. A language tag such as C<en-GB> inside the C<HTTP_USER_AGENT> string.
=item 2. L<HTTP::BrowserDetect>, if it is installed.
=item 3. L</country>.
=item 4. C<GEOIP_COUNTRY_CODE>.
=back
Returns C<undef> when nothing is found.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'object',
isa => 'Locale::Object::Country',
optional => 1,
}
=head3 EXAMPLE
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $l = CGI::Lingua->new(supported => ['en']);
if(my $locale = $l->locale()) {
print $locale->name(), "\n"; # e.g. "United States"
print $locale->currency()->code(), "\n"; # e.g. "USD"
}
=head3 MESSAGES
"HTTP_USER_AGENT contains invalid characters or exceeds length limit; ignoring"
=head3 PSEUDOCODE
1. Return the remembered answer if there is one
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 | |||||
| 2318 | ||||||
| 2319 | sub locale { | |||||
| 2320 | 32 | 1313 | my $self = shift; | |||
| 2321 | 32 | 99 | local ($@, $!); # evals below must not leak to the caller | |||
| 2322 | ||||||
| 2323 | 32 | 70 | return $self->{_locale} if $self->{_locale}; | |||
| 2324 | ||||||
| 2325 | # Validate and untaint HTTP_USER_AGENT before passing to any parser. | |||||
| 2326 | # The User-Agent header is attacker-controlled; apply the same discipline | |||||
| 2327 | # as HTTP_ACCEPT_LANGUAGE. Printable ASCII (0x20-0x7e), bounded length. | |||||
| 2328 | 30 | 24 | my $agent; | |||
| 2329 | 30 | 56 | if(defined(my $raw_agent = $ENV{'HTTP_USER_AGENT'})) { | |||
| 2330 | 18 | 38 | if($raw_agent =~ /^([\x20-\x7e]{1,$UA_MAX})$/a) { | |||
| 2331 | 13 | 154 | $agent = $1; # untainted | |||
| 2332 | } else { | |||||
| 2333 | 5 | 80 | $self->_warn({ warning => 'HTTP_USER_AGENT contains invalid characters or exceeds length limit; ignoring' }); | |||
| 2334 | } | |||||
| 2335 | } | |||||
| 2336 | ||||||
| 2337 | # First try: parse the language tag from the User-Agent parenthetical | |||||
| 2338 | 30 | 540 | if(defined($agent) && ($agent =~ /\((.+)\)/)) { | |||
| 2339 | 12 | 28 | foreach(split(/;/, $1)) { | |||
| 2340 | 25 | 17 | my $candidate = $_; | |||
| 2341 | 25 | 60 | $candidate =~ s/^\s+|\s+$//g; # trim both ends | |||
| 2342 | ||||||
| 2343 | 25 | 57 | if($candidate =~ /^[a-zA-Z]{2}-([a-zA-Z]{2})$/) { | |||
| 2344 | 5 | 11 | local $SIG{__WARN__} = undef; | |||
| 2345 | 5 | 11 | if(my $c = $self->_code2country($1)) { | |||
| 2346 | 5 | 10 | $self->{_locale} = $c; | |||
| 2347 | 5 | 18 | return $c; | |||
| 2348 | } | |||||
| 2349 | } | |||||
| 2350 | } | |||||
| 2351 | ||||||
| 2352 | # Second try: HTTP::BrowserDetect (works for more User-Agents) | |||||
| 2353 | 7 7 | 7 1404 | if(eval { require HTTP::BrowserDetect }) { | |||
| 2354 | 6 | 24022 | HTTP::BrowserDetect->import(); | |||
| 2355 | 6 | 14 | my $browser = HTTP::BrowserDetect->new($agent); | |||
| 2356 | # Validate country() result before use â the return value comes from | |||||
| 2357 | # the third-party module and is not yet untainted or range-checked. | |||||
| 2358 | 6 | 579 | if($browser) { | |||
| 2359 | 6 | 10 | my $bc = $browser->country() // ''; | |||
| 2360 | 6 | 195 | if($bc =~ /^([A-Za-z]{2})$/a) { | |||
| 2361 | 1 | 3 | if(my $c = $self->_code2country($1)) { | |||
| 2362 | 1 | 3 | $self->{_locale} = $c; | |||
| 2363 | 1 | 8 | return $c; | |||
| 2364 | } | |||||
| 2365 | } | |||||
| 2366 | } | |||||
| 2367 | } | |||||
| 2368 | } | |||||
| 2369 | ||||||
| 2370 | # Third try: IP address | |||||
| 2371 | 24 | 165 | my $country = $self->country(); | |||
| 2372 | 24 | 35 | if($country) { | |||
| 2373 | 10 | 12 | $country =~ s/[\r\n]//g; | |||
| 2374 | 10 | 10 | my $c; | |||
| 2375 | 10 | 10 | eval { | |||
| 2376 | 10 0 | 26 0 | local $SIG{__WARN__} = sub { die $_[0] }; | |||
| 2377 | 10 | 18 | $c = $self->_code2country($country); | |||
| 2378 | }; | |||||
| 2379 | 10 | 20 | unless($@) { | |||
| 2380 | 10 | 14 | if($c) { | |||
| 2381 | 10 | 12 | $self->{_locale} = $c; | |||
| 2382 | 10 | 25 | return $c; | |||
| 2383 | } | |||||
| 2384 | } | |||||
| 2385 | } | |||||
| 2386 | ||||||
| 2387 | # Fourth try: mod_geoip env var â apply the same ISO 3166-1 validation | |||||
| 2388 | # used in country() to guard against spoofed or malformed values | |||||
| 2389 | 14 | 24 | if(defined($ENV{'GEOIP_COUNTRY_CODE'})) { | |||
| 2390 | 3 | 9 | if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})\z/a) { | |||
| 2391 | 0 | 0 | if(my $c = $self->_code2country(lc($1))) { | |||
| 2392 | 0 | 0 | $self->{_locale} = $c; | |||
| 2393 | 0 | 0 | return $c; | |||
| 2394 | } | |||||
| 2395 | } | |||||
| 2396 | } | |||||
| 2397 | 14 | 30 | return undef; | |||
| 2398 | } | |||||
| 2399 | ||||||
| 2400 - 2463 | =head2 time_zone
Returns the visitor's time zone, as an IANA time zone name,
for example C<'Europe/London'> or C<'America/New_York'>.
When C<REMOTE_ADDR> is set, it uses L<Geo::IP> if a database is installed,
and otherwise asks the ip-api.com web service (this needs
L<LWP::Simple::WithCache> or L<LWP::Simple>, and L<JSON::Parse>).
When C<REMOTE_ADDR> is not set (for example on the command line),
it returns the time zone of the computer that runs the program.
Returns C<undef> when the time zone cannot be found.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
matches => qr/^[A-Za-z][A-Za-z0-9_+\-\/]*$/,
optional => 1,
}
=head3 EXAMPLE
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->time_zone() // 'unknown'; # e.g. "America/New_York"
=head3 MESSAGES
These are warnings. C<time_zone()> does not die.
"Couldn't determine the timezone"
"X.X.X.X isn't a valid IP address"
"LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com"
"JSON::Parse is absent; cannot read ip-api.com answers"
"ip-api.com returned unparseable JSON: ..."
"DateTime::TimeZone::Local failed: ..."
"Discarding malformed timezone '...'"
"ip-api.com lookup failed: ..."
=head3 PSEUDOCODE
1. Return the remembered answer if there is one
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, or JSON::Parse,
is installed (the warning names the missing module)
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. Discard the answer if it does not look like an IANA name
5. Warn "Couldn't determine the timezone" and return undef if all fail
=cut | |||||
| 2464 | ||||||
| 2465 | sub time_zone { | |||||
| 2466 | 113 | 2548 | my $self = shift; | |||
| 2467 | 113 | 311 | local ($@, $!); # evals, open() and HTTP calls must not leak to the caller | |||
| 2468 | ||||||
| 2469 | 113 | 146 | $self->_trace('Entered time_zone'); | |||
| 2470 | ||||||
| 2471 | 112 | 2138 | if($self->{_timezone}) { | |||
| 2472 | 3 | 7 | $self->_trace('quick return: ', $self->{_timezone}); | |||
| 2473 | 3 | 85 | return $self->{_timezone}; | |||
| 2474 | } | |||||
| 2475 | ||||||
| 2476 | 109 | 90 | my $raw_ip = $ENV{'REMOTE_ADDR'}; | |||
| 2477 | ||||||
| 2478 | 109 | 98 | if(defined $raw_ip) { | |||
| 2479 | # Untaint before any external use â kept in sync with country()'s pattern, | |||||
| 2480 | # including the mixed-notation branch for ::ffff:a.b.c.d addresses. | |||||
| 2481 | 40 | 49 | my $ip = _untaint_ip($raw_ip); | |||
| 2482 | 40 | 222 | unless(defined($ip)) { | |||
| 2483 | 5 | 12 | $self->_warn({ warning => "$raw_ip isn't a valid IP address" }); | |||
| 2484 | 5 | 15 | return undef; | |||
| 2485 | } | |||||
| 2486 | ||||||
| 2487 | 35 | 62 | if($self->{_have_geoip} == $GEO_UNKNOWN) { | |||
| 2488 | 9 | 38 | $self->_load_geoip(); | |||
| 2489 | } | |||||
| 2490 | 35 | 86 | if($self->{_have_geoip} == $GEO_PRESENT) { | |||
| 2491 | 1 1 | 3 3 | eval { $self->{_timezone} = $self->{_geoip}->time_zone($ip) }; | |||
| 2492 | } | |||||
| 2493 | ||||||
| 2494 | 35 | 144 | unless($self->{_timezone}) { | |||
| 2495 | 34 34 26 | 30 111 34868 | if(eval { require LWP::Simple::WithCache; require JSON::Parse }) { | |||
| 2496 | 24 | 44 | $self->_debug("Look up $ip on ip-api.com"); | |||
| 2497 | ||||||
| 2498 | 24 24 24 | 316 37 44 | my $data = eval { local $SIG{__DIE__}; LWP::Simple::WithCache::get("http://ip-api.com/json/$ip") }; | |||
| 2499 | 24 | 267 | $self->_warn({ warning => "ip-api.com lookup failed: $@" }) if $@; | |||
| 2500 | 24 | 305 | if($data) { | |||
| 2501 | 16 16 | 14 85 | eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} }; | |||
| 2502 | 16 | 34 | $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@; | |||
| 2503 | } | |||||
| 2504 | 10 5 | 952 13 | } elsif(eval { require LWP::Simple; require JSON::Parse }) { | |||
| 2505 | 2 | 4 | $self->_debug("Look up $ip on ip-api.com"); | |||
| 2506 | ||||||
| 2507 | 2 2 2 | 28 4 5 | my $data = eval { local $SIG{__DIE__}; LWP::Simple::get("http://ip-api.com/json/$ip") }; | |||
| 2508 | 2 | 84 | $self->_warn({ warning => "ip-api.com lookup failed: $@" }) if $@; | |||
| 2509 | 2 | 4 | if($data) { | |||
| 2510 | 2 2 | 1 11 | eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} }; | |||
| 2511 | 2 | 6 | $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@; | |||
| 2512 | } | |||||
| 2513 | } else { | |||||
| 2514 | # A module is missing â degrade gracefully rather than killing the | |||||
| 2515 | # entire request with a croak; caller can check for undef. Name the | |||||
| 2516 | # module that is really missing: an LWP may be present without JSON::Parse. | |||||
| 2517 | 8 8 2 6 1 | 596 21 5 172 2 | if(eval { require LWP::Simple::WithCache; 1 } || eval { require LWP::Simple; 1 }) { | |||
| 2518 | 3 | 8 | $self->_warn({ warning => 'JSON::Parse is absent; cannot read ip-api.com answers' }); | |||
| 2519 | } else { | |||||
| 2520 | 5 | 219 | $self->_warn({ warning => 'LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com' }); | |||
| 2521 | } | |||||
| 2522 | } | |||||
| 2523 | } | |||||
| 2524 | } else { | |||||
| 2525 | # Local connection â read from /etc/timezone or DateTime::TimeZone | |||||
| 2526 | 69 | 63 | if(defined(my $tz = $self->_read_zone_file())) { | |||
| 2527 | 56 | 37 | $self->{_timezone} = $tz; | |||
| 2528 | } else { | |||||
| 2529 | # DateTime::TimeZone::Local::TimeZone() is not available on all | |||||
| 2530 | # platforms/versions (absent on some Windows Perl builds); guard | |||||
| 2531 | # so time_zone() degrades to undef rather than dying. | |||||
| 2532 | 13 | 12 | eval { | |||
| 2533 | 13 | 19 | local $SIG{__DIE__}; | |||
| 2534 | 13 | 30 | require DateTime::TimeZone::Local; | |||
| 2535 | 13 | 28 | $self->{_timezone} = DateTime::TimeZone::Local->TimeZone()->name(); | |||
| 2536 | }; | |||||
| 2537 | 13 | 71 | $self->_warn({ warning => "DateTime::TimeZone::Local failed: $@" }) if $@; | |||
| 2538 | } | |||||
| 2539 | } | |||||
| 2540 | ||||||
| 2541 | # Validate the timezone string against a permissive but bounded IANA pattern. | |||||
| 2542 | # Rejects XSS payloads (e.g. "Europe/London<script>...") from hostile JSON | |||||
| 2543 | # responses while accepting all real IANA zone names (e.g. "America/New_York", | |||||
| 2544 | # "Etc/GMT+8", "UTC"). | |||||
| 2545 | 104 | 211 | if(defined($self->{_timezone}) && | |||
| 2546 | (ref($self->{_timezone}) || ($self->{_timezone} !~ $ZONE_RE))) { | |||||
| 2547 | 4 | 29 | $self->_warn({ warning => "Discarding malformed timezone '" . _printable($self->{_timezone}) . "'" }); | |||
| 2548 | 4 | 223 | delete $self->{_timezone}; | |||
| 2549 | } | |||||
| 2550 | ||||||
| 2551 | 104 | 302 | unless(defined($self->{_timezone})) { | |||
| 2552 | 27 | 47 | $self->_warn({ warning => "Couldn't determine the timezone" }); | |||
| 2553 | } | |||||
| 2554 | 104 | 1003 | return $self->{_timezone}; | |||
| 2555 | } | |||||
| 2556 | ||||||
| 2557 | # ââ _read_zone_file ââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2558 | # Purpose: Read the system time zone name from $ZONE_FILE (normally | |||||
| 2559 | # /etc/timezone) for command-line use. | |||||
| 2560 | # Entry: none. | |||||
| 2561 | # Exit: The zone name, or undef if the file is missing, unreadable, | |||||
| 2562 | # not a regular file, empty, or does not hold a zone name. | |||||
| 2563 | # Notes: Only a regular file is opened, and at most $ZONE_FILE_MAX bytes | |||||
| 2564 | # are read: a symlink to /dev/zero or /dev/urandom, or a huge | |||||
| 2565 | # file, must not hang the request or exhaust memory. CORE:: | |||||
| 2566 | # calls bypass autodie, so a failure returns undef, not a die. | |||||
| 2567 | sub _read_zone_file | |||||
| 2568 | { | |||||
| 2569 | 69 | 40 | my $self = shift; | |||
| 2570 | ||||||
| 2571 | 69 | 486 | return undef unless -f $ZONE_FILE && -r _; | |||
| 2572 | 61 | 541 | CORE::open(my $fin, '<', $ZONE_FILE) or return undef; | |||
| 2573 | 61 | 67 | my $got = CORE::read($fin, my $buf, $ZONE_FILE_MAX); | |||
| 2574 | 61 | 833 | CORE::close($fin); | |||
| 2575 | 61 | 47 | return undef unless $got; | |||
| 2576 | ||||||
| 2577 | # First word of the file; surrounding spaces and newlines are not part of it | |||||
| 2578 | 60 | 81 | my ($zone) = $buf =~ /\A\s*(\S+)/; | |||
| 2579 | 60 | 111 | unless(defined($zone) && ($zone =~ $ZONE_RE)) { | |||
| 2580 | 4 | 16 | $self->_debug("$ZONE_FILE does not hold a time zone name"); | |||
| 2581 | 4 | 15 | return undef; | |||
| 2582 | } | |||||
| 2583 | 56 | 215 | return $zone; | |||
| 2584 | } | |||||
| 2585 | ||||||
| 2586 - 2616 | =head2 is_rtl
Returns C<1> if the chosen language is written from right to left,
and C<0> if it is not.
The right-to-left languages are Arabic (C<ar>), Dhivehi (C<dv>),
Persian (C<fa>), Hebrew (C<he>), Kurdish (C<ku>), Pashto (C<ps>),
Sindhi (C<sd>), Uyghur (C<ug>), Urdu (C<ur>) and Yiddish (C<yi>).
When no language was found, it returns C<0>.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'boolean',
memberof => [0, 1],
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ar';
my $l = CGI::Lingua->new(supported => ['ar', 'en']);
print $l->is_rtl(); # 1
=cut | |||||
| 2617 | ||||||
| 2618 | sub is_rtl | |||||
| 2619 | { | |||||
| 2620 | 16 | 38 | my $self = shift; | |||
| 2621 | 16 | 32 | return $RTL_LANGS{$self->language_code_alpha2() // ''} ? 1 : 0; | |||
| 2622 | } | |||||
| 2623 | ||||||
| 2624 - 2650 | =head2 text_direction
Returns C<'rtl'> (right to left) or C<'ltr'> (left to right) for the chosen
language. You can use the value directly in the HTML C<dir> attribute.
When no language was found, it returns C<'ltr'>.
=head3 API SPECIFICATION
=head4 Input
{}
=head4 Output
{
type => 'string',
memberof => ['ltr', 'rtl'],
}
=head3 EXAMPLE
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'he';
my $l = CGI::Lingua->new(supported => ['he', 'en']);
print '<html dir="', $l->text_direction(), '">'; # <html dir="rtl">
=cut | |||||
| 2651 | ||||||
| 2652 | sub text_direction | |||||
| 2653 | { | |||||
| 2654 | 6 | 23 | my $self = shift; | |||
| 2655 | 6 | 11 | return $self->is_rtl() ? 'rtl' : 'ltr'; | |||
| 2656 | } | |||||
| 2657 | ||||||
| 2658 - 2709 | =head2 plural_category($n)
Many languages use a different word form for different numbers.
English has two forms ("1 file", "2 files"); Russian has more;
Japanese has only one.
C<plural_category($n)> tells you which form to use for the number C<$n>
in the chosen language.
It returns one of C<'zero'>, C<'one'>, C<'two'>, C<'few'>, C<'many'> or
C<'other'>. These are the Unicode CLDR plural category names.
The rules for about 70 languages are built in, including Arabic (6 forms),
Slavic languages (3 or 4 forms), Celtic languages (up to 6 forms), Hebrew,
Maltese, Romanian, Latvian, Lithuanian and Slovenian.
Other languages use the English rule: C<'one'> for 1, otherwise C<'other'>.
When no language was found, it always returns C<'other'>.
C<$n> should be a whole number that is zero or more.
A number with a fractional part is cut down to a whole number first
(2.7 becomes 2).
For full CLDR rules, including fractions, use L<Locale::CLDR>.
=head3 API SPECIFICATION
=head4 Input
[
{ type => 'number', min => 0 },
]
=head4 Output
{
type => 'string',
memberof => ['zero', 'one', 'two', 'few', 'many', 'other'],
}
=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 MESSAGES
"plural_category: $n must be defined"
- dies (croak) when $n is undef
=cut | |||||
| 2710 | ||||||
| 2711 | # CLDR plural category rules (https://unicode.org/cldr/charts/42/supplemental/language_plural_rules.html). | |||||
| 2712 | # Values are coderefs: ($n) â category string. Languages absent from this | |||||
| 2713 | # table fall back to the standard one/other rule inside plural_category(). | |||||
| 2714 | my %PLURAL_RULES = ( | |||||
| 2715 | ||||||
| 2716 | # ââ No plural distinction (always 'other') ââââââââââââââââââââââââââââ | |||||
| 2717 | (map { $_ => sub { 'other' } } | |||||
| 2718 | qw(az bm bo dz id ig ii in ja jbo jv jw kde kea km ko lkt lo ms | |||||
| 2719 | my nqo root sah ses sg th to vi wo yo zh)), | |||||
| 2720 | ||||||
| 2721 | # ââ Standard: n == 1 â 'one', else 'other' âââââââââââââââââââââââââââ | |||||
| 2722 | (map { $_ => sub { int($_[0]) == 1 ? 'one' : 'other' } } | |||||
| 2723 | qw(af an ast bg bn brx ca cgg da de el en eo es et eu fi | |||||
| 2724 | gl gsw gu ha haw hu ia it kcg kk kl lb lg mas ml mn mr | |||||
| 2725 | nb nd ne nl nn nyn om or pa pap rm rof rwk saq seh sn so | |||||
| 2726 | sq ss ssy st sv sw ta te teo tig tk tl tn ts ur wae xh xog)), | |||||
| 2727 | ||||||
| 2728 | # ââ French / Portuguese-BR: n ⤠1 â 'one' ââââââââââââââââââââââââââââ | |||||
| 2729 | (map { $_ => sub { int($_[0]) <= 1 ? 'one' : 'other' } } qw(fr pt_BR)), | |||||
| 2730 | ||||||
| 2731 | # ââ Arabic: zero/one/two/few/many/other âââââââââââââââââââââââââââââââ | |||||
| 2732 | ar => sub { | |||||
| 2733 | my $n = int($_[0]); | |||||
| 2734 | my $m100 = $n % 100; | |||||
| 2735 | return 'zero' if $n == 0; | |||||
| 2736 | return 'one' if $n == 1; | |||||
| 2737 | return 'two' if $n == 2; | |||||
| 2738 | return 'few' if $m100 >= 3 && $m100 <= 10; | |||||
| 2739 | return 'many' if $m100 >= 11 && $m100 <= 99; | |||||
| 2740 | return 'other'; | |||||
| 2741 | }, | |||||
| 2742 | ||||||
| 2743 | # ââ Hebrew: one/two/many/other ââââââââââââââââââââââââââââââââââââââââ | |||||
| 2744 | he => sub { | |||||
| 2745 | my $n = int($_[0]); | |||||
| 2746 | return 'one' if $n == 1; | |||||
| 2747 | return 'two' if $n == 2; | |||||
| 2748 | return 'many' if $n != 0 && $n % 10 == 0; | |||||
| 2749 | return 'other'; | |||||
| 2750 | }, | |||||
| 2751 | ||||||
| 2752 | # ââ Russian / Ukrainian / Belarusian: one/few/many ââââââââââââââââââââ | |||||
| 2753 | (map { $_ => sub { | |||||
| 2754 | my $n = int($_[0]); | |||||
| 2755 | my $m10 = $n % 10; | |||||
| 2756 | my $m100 = $n % 100; | |||||
| 2757 | return 'one' if $m10 == 1 && $m100 != 11; | |||||
| 2758 | return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20); | |||||
| 2759 | return 'many'; | |||||
| 2760 | } } qw(ru uk be)), | |||||
| 2761 | ||||||
| 2762 | # ââ Polish: one/few/many ââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2763 | pl => sub { | |||||
| 2764 | my $n = int($_[0]); | |||||
| 2765 | my $m10 = $n % 10; | |||||
| 2766 | my $m100 = $n % 100; | |||||
| 2767 | return 'one' if $n == 1; | |||||
| 2768 | return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20); | |||||
| 2769 | return 'many'; | |||||
| 2770 | }, | |||||
| 2771 | ||||||
| 2772 | # ââ Czech / Slovak: one/few/other ââââââââââââââââââââââââââââââââââââ | |||||
| 2773 | (map { $_ => sub { | |||||
| 2774 | my $n = int($_[0]); | |||||
| 2775 | return 'one' if $n == 1; | |||||
| 2776 | return 'few' if $n >= 2 && $n <= 4; | |||||
| 2777 | return 'other'; | |||||
| 2778 | } } qw(cs sk)), | |||||
| 2779 | ||||||
| 2780 | # ââ Romanian: one/few/other âââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2781 | ro => sub { | |||||
| 2782 | my $n = int($_[0]); | |||||
| 2783 | my $m100 = $n % 100; | |||||
| 2784 | return 'one' if $n == 1; | |||||
| 2785 | return 'few' if $n == 0 || ($m100 >= 1 && $m100 <= 19); | |||||
| 2786 | return 'other'; | |||||
| 2787 | }, | |||||
| 2788 | ||||||
| 2789 | # ââ Latvian: zero/one/other âââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2790 | lv => sub { | |||||
| 2791 | my $n = int($_[0]); | |||||
| 2792 | my $m10 = $n % 10; | |||||
| 2793 | my $m100 = $n % 100; | |||||
| 2794 | return 'zero' if $m10 == 0 || ($m100 >= 11 && $m100 <= 19); | |||||
| 2795 | return 'one' if $m10 == 1 && $m100 != 11; | |||||
| 2796 | return 'other'; | |||||
| 2797 | }, | |||||
| 2798 | ||||||
| 2799 | # ââ Lithuanian: one/few/other âââââââââââââââââââââââââââââââââââââââââ | |||||
| 2800 | lt => sub { | |||||
| 2801 | my $n = int($_[0]); | |||||
| 2802 | my $m10 = $n % 10; | |||||
| 2803 | my $m100 = $n % 100; | |||||
| 2804 | return 'one' if $m10 == 1 && ($m100 < 10 || $m100 >= 20); | |||||
| 2805 | return 'few' if $m10 >= 2 && ($m100 < 10 || $m100 >= 20); | |||||
| 2806 | return 'other'; | |||||
| 2807 | }, | |||||
| 2808 | ||||||
| 2809 | # ââ Slovenian: one/two/few/other ââââââââââââââââââââââââââââââââââââââ | |||||
| 2810 | sl => sub { | |||||
| 2811 | my $m100 = int($_[0]) % 100; | |||||
| 2812 | return 'one' if $m100 == 1; | |||||
| 2813 | return 'two' if $m100 == 2; | |||||
| 2814 | return 'few' if $m100 == 3 || $m100 == 4; | |||||
| 2815 | return 'other'; | |||||
| 2816 | }, | |||||
| 2817 | ||||||
| 2818 | # ââ Welsh: zero/one/two/few/many/other âââââââââââââââââââââââââââââââ | |||||
| 2819 | cy => sub { | |||||
| 2820 | my $n = int($_[0]); | |||||
| 2821 | return 'zero' if $n == 0; | |||||
| 2822 | return 'one' if $n == 1; | |||||
| 2823 | return 'two' if $n == 2; | |||||
| 2824 | return 'few' if $n == 3; | |||||
| 2825 | return 'many' if $n == 6; | |||||
| 2826 | return 'other'; | |||||
| 2827 | }, | |||||
| 2828 | ||||||
| 2829 | # ââ Irish: one/two/few/many/other ââââââââââââââââââââââââââââââââââââ | |||||
| 2830 | ga => sub { | |||||
| 2831 | my $n = int($_[0]); | |||||
| 2832 | return 'one' if $n == 1; | |||||
| 2833 | return 'two' if $n == 2; | |||||
| 2834 | return 'few' if $n >= 3 && $n <= 6; | |||||
| 2835 | return 'many' if $n >= 7 && $n <= 10; | |||||
| 2836 | return 'other'; | |||||
| 2837 | }, | |||||
| 2838 | ||||||
| 2839 | # ââ Maltese: one/two/few/many/other ââââââââââââââââââââââââââââââââââ | |||||
| 2840 | mt => sub { | |||||
| 2841 | my $n = int($_[0]); | |||||
| 2842 | my $m100 = $n % 100; | |||||
| 2843 | return 'one' if $n == 1; | |||||
| 2844 | return 'two' if $n == 2; | |||||
| 2845 | return 'few' if $n == 0 || ($m100 >= 3 && $m100 <= 10); | |||||
| 2846 | return 'many' if $m100 >= 11 && $m100 <= 19; | |||||
| 2847 | return 'other'; | |||||
| 2848 | }, | |||||
| 2849 | ); | |||||
| 2850 | ||||||
| 2851 | sub plural_category | |||||
| 2852 | { | |||||
| 2853 | 31 | 570 | my ($self, $n) = @_; | |||
| 2854 | 31 | 67 | Carp::croak('plural_category: $n must be defined') unless defined $n; | |||
| 2855 | 28 | 33 | my $code = $self->language_code_alpha2() // return 'other'; | |||
| 2856 | 26 0 | 34 0 | my $rule = $PLURAL_RULES{$code} // sub { int($_[0]) == 1 ? 'one' : 'other' }; | |||
| 2857 | 26 | 30 | return $rule->($n); | |||
| 2858 | } | |||||
| 2859 | ||||||
| 2860 - 2924 | =head2 translation_file($dir, $ext)
Finds the translation file for the chosen language in the directory C<$dir>,
and returns its path.
It tries these file names, in order, and returns the first one that is a
readable regular file:
=over 4
=item 1. C<$dir/$lang-$sublang.$ext> (for example F<en-gb.json>)
=item 2. C<$dir/$lang.$ext> (for example F<en.json>)
=back
C<$ext> is the file extension. It is C<'json'> if you do not give it.
You can write it with or without the dot (C<'po'> or C<'.po'>).
Returns C<undef> when no such file exists, when no language was found,
when C<$dir> is C<undef>, or when C<$dir> or C<$ext> is unsafe (see below).
A directory, a device (such as F</dev/urandom>), a broken symbolic link or a
file you cannot read is never returned, even if it has the right name.
For safety, C<$dir> must be a non-empty string (not a reference) without
C<..> or a null byte, and C<$ext> may only contain letters, digits and C<->.
Control characters in a rejected value are shown as C<\xNN> in the warning.
=head3 API SPECIFICATION
=head4 Input
[
{ type => 'string', min => 1 },
{ type => 'string', min => 1, matches => qr/^\.?[A-Za-z0-9-]+$/, optional => 1 },
]
=head4 Output
{
type => 'string',
optional => 1,
}
=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');
# '/var/www/i18n/en-gb.json' if it exists,
# else '/var/www/i18n/en.json' if it exists,
# else undef
# A different extension
my $po = $l->translation_file('/var/www/i18n', 'po');
=head3 MESSAGES
These are warnings. C<translation_file()> does not die.
"translation_file: unsafe directory '...' rejected"
"translation_file: unsafe extension '...' rejected"
=cut | |||||
| 2925 | ||||||
| 2926 | sub translation_file | |||||
| 2927 | { | |||||
| 2928 | 46 | 11447 | my ($self, $dir, $ext) = @_; | |||
| 2929 | 46 | 236 | local $!; # a failed file test sets $! | |||
| 2930 | 46 | 63 | return undef unless defined $dir; | |||
| 2931 | ||||||
| 2932 | # Reject traversal attempts in the directory argument. A real translation | |||||
| 2933 | # directory never needs '..' or null bytes; an empty name would mean "/", | |||||
| 2934 | # and a reference would be used as the string "ARRAY(0x...)". | |||||
| 2935 | 45 | 204 | if(ref($dir) || !length($dir) || $dir =~ /\.\./ || $dir =~ /\x00/) { | |||
| 2936 | 9 | 13 | $self->_warn({ warning => "translation_file: unsafe directory '" . _printable($dir) . "' rejected" }); | |||
| 2937 | 9 | 309 | return undef; | |||
| 2938 | } | |||||
| 2939 | ||||||
| 2940 | 36 | 80 | $ext //= 'json'; | |||
| 2941 | 36 | 36 | $ext =~ s/^\.//; # accept '.json' or 'json' | |||
| 2942 | ||||||
| 2943 | # Valid extensions are letters, digits and hyphens only (e.g. 'json', 'po', | |||||
| 2944 | # 'yml'); \z, not $, so "json\n" is refused too | |||||
| 2945 | 36 | 77 | unless($ext =~ /^[A-Za-z0-9\-]+\z/) { | |||
| 2946 | 9 | 14 | $self->_warn({ warning => "translation_file: unsafe extension '" . _printable($ext) . "' rejected" }); | |||
| 2947 | 9 | 492 | return undef; | |||
| 2948 | } | |||||
| 2949 | ||||||
| 2950 | # The codes become part of a path, so check their shape even though they | |||||
| 2951 | # come from the negotiated language | |||||
| 2952 | 27 | 40 | my $lang = $self->language_code_alpha2(); | |||
| 2953 | 27 | 71 | return undef unless defined($lang) && ($lang =~ $LANG_CODE_RE); | |||
| 2954 | 26 | 112 | my @candidates; | |||
| 2955 | 26 | 40 | if(defined(my $sub = $self->sublanguage_code_alpha2())) { | |||
| 2956 | 4 | 9 | push @candidates, "$lang-$sub" if $sub =~ $LANG_CODE_RE; | |||
| 2957 | } | |||||
| 2958 | 26 | 44 | push @candidates, $lang; | |||
| 2959 | ||||||
| 2960 | # A translation file must be a readable regular file: a directory, a | |||||
| 2961 | # device such as /dev/urandom, a dangling symlink or an unreadable file | |||||
| 2962 | # named en.json is not one, and handing it back would only fail later | |||||
| 2963 | 26 | 26 | for my $code (@candidates) { | |||
| 2964 | 29 | 35 | my $path = "$dir/$code.$ext"; | |||
| 2965 | 29 | 250 | return $path if -f $path && -r _; | |||
| 2966 | } | |||||
| 2967 | 11 | 32 | return undef; | |||
| 2968 | } | |||||
| 2969 | ||||||
| 2970 | # ââ _printable ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2971 | # Purpose: Make a caller-supplied value safe to put in a log message: | |||||
| 2972 | # control and non-ASCII characters (newlines that would forge | |||||
| 2973 | # log lines, NULs, terminal escapes) become \xNN. | |||||
| 2974 | # Entry: $value â any scalar; a reference is shown as its type. | |||||
| 2975 | # Exit: A printable ASCII string. | |||||
| 2976 | sub _printable | |||||
| 2977 | { | |||||
| 2978 | 1247 | 762 | my $value = shift; | |||
| 2979 | 1247 | 965 | return 'undef' unless defined($value); | |||
| 2980 | 1245 | 920 | return ref($value) . ' reference' if ref($value); | |||
| 2981 | 1239 244 | 1261 622 | $value =~ s/([^\x20-\x7e])/sprintf('\\x%02x', ord($1))/ge; | |||
| 2982 | 1239 | 1188 | return $value; | |||
| 2983 | } | |||||
| 2984 | ||||||
| 2985 | # ââ _code2language ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 2986 | # Purpose: Translate a 2-char language code to its English name, with | |||||
| 2987 | # optional CHI caching. | |||||
| 2988 | # Entry: $code â 2-char ISO 639-1 code; must be defined and non-empty. | |||||
| 2989 | # Exit: Human-readable language name string, or undef. | |||||
| 2990 | # Side Effects: Reads/writes cache. | |||||
| 2991 | sub _code2language | |||||
| 2992 | { | |||||
| 2993 | 242 | 1259 | my ($self, $code) = @_; | |||
| 2994 | ||||||
| 2995 | 242 | 230 | return unless $code; | |||
| 2996 | 240 | 256 | if(defined($self->{_country})) { | |||
| 2997 | 3 | 7 | $self->_debug("_code2language $code, country ", $self->{_country}); | |||
| 2998 | } else { | |||||
| 2999 | 237 | 261 | $self->_debug("_code2language $code"); | |||
| 3000 | } | |||||
| 3001 | ||||||
| 3002 | 240 | 6459 | unless($self->{_cache}) { | |||
| 3003 | 188 | 272 | return Locale::Language::code2language($code); | |||
| 3004 | } | |||||
| 3005 | ||||||
| 3006 | 52 | 95 | if(my $from_cache = $self->_cache_get_valid($CACHE_NS . "code2language:$code", $NAME_RE)) { | |||
| 3007 | 8 | 14 | $self->_trace("_code2language found in cache $from_cache"); | |||
| 3008 | 8 | 151 | return $from_cache; | |||
| 3009 | } | |||||
| 3010 | ||||||
| 3011 | # Compute, cache, then return the value separately â | |||||
| 3012 | # CHI->set() is not guaranteed to return the stored value across all drivers | |||||
| 3013 | 44 | 80 | $self->_trace('_code2language not in cache, storing'); | |||
| 3014 | 44 | 1120 | my $name = Locale::Language::code2language($code); | |||
| 3015 | 44 | 434967 | if(defined $name) { | |||
| 3016 | 43 | 89 | $self->_cache_call($self->{_cache}, 'set', $CACHE_NS . "code2language:$code", $name, $CACHE_TTL_LONG); | |||
| 3017 | } | |||||
| 3018 | 44 | 65 | return $name; | |||
| 3019 | } | |||||
| 3020 | ||||||
| 3021 | # ââ _code2country âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3022 | # Purpose: Translate a 2-char country code to a Locale::Object::Country | |||||
| 3023 | # object, suppressing the expected "No result found" warning. | |||||
| 3024 | # Entry: $code â 2-char ISO 3166-1 alpha-2 code (any case). | |||||
| 3025 | # Exit: Locale::Object::Country object, or undef. | |||||
| 3026 | # Side Effects: None beyond the Locale::Object::Country look-up. | |||||
| 3027 | sub _code2country | |||||
| 3028 | { | |||||
| 3029 | 165 | 601 | my ($self, $code) = @_; | |||
| 3030 | ||||||
| 3031 | 165 | 243 | return unless $code; | |||
| 3032 | 163 | 203 | if($self->{_country}) { | |||
| 3033 | 47 | 95 | $self->_trace(">_code2country $code, country ", $self->{_country}); | |||
| 3034 | } else { | |||||
| 3035 | 116 | 142 | $self->_trace(">_code2country $code"); | |||
| 3036 | } | |||||
| 3037 | ||||||
| 3038 | 163 | 3684 | my $rc; | |||
| 3039 | 163 | 364 | if($_locale_object_db_ok // 1) { | |||
| 3040 | # Suppress the routine "No result found" warning; catch the database- | |||||
| 3041 | # absent exception that Windows installations sometimes throw. | |||||
| 3042 | 163 | 320 | local $SIG{__WARN__} = _warn_filter(qr/No result found in country table/); | |||
| 3043 | 163 163 | 135 669 | eval { $rc = Locale::Object::Country->new(code_alpha2 => $code) }; | |||
| 3044 | 163 | 10547397 | if($@) { | |||
| 3045 | 0 | 0 | $_locale_object_db_ok = 0 | |||
| 3046 | if $@ =~ /database was not in/; | |||||
| 3047 | 0 | 0 | $rc = undef; | |||
| 3048 | } else { | |||||
| 3049 | 163 | 897 | $_locale_object_db_ok = 1; | |||
| 3050 | } | |||||
| 3051 | } | |||||
| 3052 | 163 | 440 | $self->_trace('<_code2country ', $code || 'undef'); | |||
| 3053 | 163 | 5290 | return $rc; | |||
| 3054 | } | |||||
| 3055 | ||||||
| 3056 | # ââ _country_short_name âââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3057 | # Purpose: Return the common short English name for an ISO 3166-1 alpha-2 | |||||
| 3058 | # code when Locale::Object's database is unavailable. Uses | |||||
| 3059 | # %COUNTRY_SHORT_NAMES overrides for codes where Locale::Codes | |||||
| 3060 | # returns the full ISO official name rather than the short form. | |||||
| 3061 | # Entry: $code â 2-char country code (any case). | |||||
| 3062 | # Exit: Short name string, or undef. | |||||
| 3063 | sub _country_short_name | |||||
| 3064 | { | |||||
| 3065 | 27 | 3203 | my ($self, $code) = @_; | |||
| 3066 | 27 | 31 | return unless defined($code); | |||
| 3067 | 25 | 35 | my $lc = lc($code); | |||
| 3068 | 25 | 43 | return $COUNTRY_SHORT_NAMES{$lc} if exists $COUNTRY_SHORT_NAMES{$lc}; | |||
| 3069 | # Locale::Object may have partially initialised Locale::Codes::Country as a | |||||
| 3070 | # dependency before we get here; suppress the spurious 'redefine' warning | |||||
| 3071 | # that some Perl/Locale::Codes combinations produce on first full load. | |||||
| 3072 | 14 14 | 12 22 | { local $SIG{__WARN__} = _warn_filter(qr/redefined/); | |||
| 3073 | 14 | 725 | require Locale::Codes::Country; } | |||
| 3074 | 14 | 57479 | return Locale::Codes::Country::code2country($lc, 'alpha-2'); | |||
| 3075 | } | |||||
| 3076 | ||||||
| 3077 | # ââ _code2countryname âââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3078 | # Purpose: Translate a 2-char country code to its English name string, | |||||
| 3079 | # with optional CHI caching. | |||||
| 3080 | # Entry: $code â 2-char ISO 3166-1 alpha-2 code. | |||||
| 3081 | # Exit: Country name string, or undef. | |||||
| 3082 | # Side Effects: Reads/writes cache. | |||||
| 3083 | sub _code2countryname | |||||
| 3084 | { | |||||
| 3085 | 86 | 591 | my ($self, $code) = @_; | |||
| 3086 | ||||||
| 3087 | 86 | 104 | return unless $code; | |||
| 3088 | 84 | 144 | $self->_trace(">_code2countryname $code"); | |||
| 3089 | ||||||
| 3090 | 84 | 3158 | unless($self->{_cache}) { | |||
| 3091 | 63 | 109 | my $country = $self->_code2country($code); | |||
| 3092 | 63 | 152 | return $country->name if defined($country); | |||
| 3093 | 3 | 5 | return $self->_country_short_name($code); | |||
| 3094 | } | |||||
| 3095 | ||||||
| 3096 | 21 | 40 | if(my $from_cache = $self->_cache_get_valid($CACHE_NS . "code2countryname:$code", $NAME_RE)) { | |||
| 3097 | 2 | 4 | $self->_trace("_code2countryname found in cache $from_cache"); | |||
| 3098 | 2 | 60 | return $from_cache; | |||
| 3099 | } | |||||
| 3100 | ||||||
| 3101 | 19 | 21 | my $name; | |||
| 3102 | 19 | 34 | if(my $country = $self->_code2country($code)) { | |||
| 3103 | 18 | 35 | $name = $country->name(); | |||
| 3104 | } else { | |||||
| 3105 | # Locale::Object database absent (common on Windows); fall back to | |||||
| 3106 | # Locale::Codes::Country with a short-name correction table. | |||||
| 3107 | 1 | 2 | $name = $self->_country_short_name($code); | |||
| 3108 | } | |||||
| 3109 | ||||||
| 3110 | 19 | 115 | if(defined($name)) { | |||
| 3111 | 18 | 27 | $self->_debug('_code2countryname not in cache, storing'); | |||
| 3112 | 18 | 431 | $self->_trace('<_code2countryname ', $name); | |||
| 3113 | 18 | 429 | $self->_cache_call($self->{_cache}, 'set', $CACHE_NS . "code2countryname:$code", $name, $CACHE_TTL_LONG); | |||
| 3114 | 18 | 32 | return $name; | |||
| 3115 | } | |||||
| 3116 | 1 | 2 | $self->_trace('<_code2countryname undef'); | |||
| 3117 | 1 | 32 | return; | |||
| 3118 | } | |||||
| 3119 | ||||||
| 3120 | # ââ _log ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3121 | # Purpose: Append a message to $self->{messages} and forward to the | |||||
| 3122 | # optional logger object. | |||||
| 3123 | # Entry: $level â log level string (debug/info/notice/warn/trace/error); | |||||
| 3124 | # @messages â one or more strings to concatenate. | |||||
| 3125 | # Exit: void | |||||
| 3126 | # Side Effects: Mutates $self->{messages}; calls logger method if set. | |||||
| 3127 | sub _log | |||||
| 3128 | { | |||||
| 3129 | 4461 | 4935 | my ($self, $level, @messages) = @_; | |||
| 3130 | ||||||
| 3131 | 4461 | 5434 | return unless ref($self) && scalar(@messages); | |||
| 3132 | ||||||
| 3133 | 4458 | 5403 | my $text = join('', grep defined, @messages); | |||
| 3134 | 4458 | 3199 | return unless length($text); | |||
| 3135 | 4455 4455 | 2306 5190 | push @{$self->{'messages'}}, { level => $level, message => $text }; | |||
| 3136 | ||||||
| 3137 | 4455 | 4145 | if(my $logger = $self->{'logger'}) { | |||
| 3138 | 3925 | 3950 | $logger->$level($text); | |||
| 3139 | } | |||||
| 3140 | } | |||||
| 3141 | ||||||
| 3142 | 2308 2308 | 2863 3927 | sub _debug { my $self = shift; $self->_log('debug', @_) } | |||
| 3143 | 8 8 | 963 11 | sub _info { my $self = shift; $self->_log('info', @_) } | |||
| 3144 | 6 6 | 799 11 | sub _notice { my $self = shift; $self->_log('notice', @_) } | |||
| 3145 | 2111 2111 | 2098 2104 | sub _trace { my $self = shift; $self->_log('trace', @_) } | |||
| 3146 | ||||||
| 3147 | # ââ _warn âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3148 | # Purpose: Emit a warning through the logger (if set) or via Carp::carp. | |||||
| 3149 | # Entry: A single hashref argument: { warning => 'message text' }. | |||||
| 3150 | # All callers MUST use this structured form â plain-string calls | |||||
| 3151 | # silently lose the message when no logger is configured. | |||||
| 3152 | # Exit: void | |||||
| 3153 | # Side Effects: Calls logger->warn() or carp(). | |||||
| 3154 | sub _warn | |||||
| 3155 | { | |||||
| 3156 | 220 | 291 | my $self = shift; | |||
| 3157 | ||||||
| 3158 | # Parse once; both branches need the same $msg. | |||||
| 3159 | 220 | 269 | my $params = Params::Get::get_params('warning', @_); | |||
| 3160 | 220 | 3497 | my $msg = (ref($params) ? $params->{'warning'} : undef) // join('', grep defined, @_); | |||
| 3161 | ||||||
| 3162 | 220 | 252 | if(defined($self->{'logger'})) { | |||
| 3163 | 202 | 283 | $self->{'logger'}->warn($msg); | |||
| 3164 | } else { | |||||
| 3165 | 18 | 21 | $self->_log('warn', $msg); | |||
| 3166 | 18 | 540 | Carp::carp($msg); | |||
| 3167 | } | |||||
| 3168 | } | |||||
| 3169 | ||||||
| 3170 | # ââ _warn_filter ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3171 | # Purpose: Build a $SIG{__WARN__} handler that drops warnings matching | |||||
| 3172 | # $skip and passes every other warning on to the handler that | |||||
| 3173 | # was active when the filter was built. | |||||
| 3174 | # Entry: $skip â compiled regex of warnings to drop. | |||||
| 3175 | # Exit: Code reference suitable for "local $SIG{__WARN__} = ...". | |||||
| 3176 | # Notes: A plain "warn" inside a __WARN__ handler bypasses every handler | |||||
| 3177 | # and goes straight to STDERR, so the caller's handler (a logger, | |||||
| 3178 | # Test::Warnings, ...) would never see the warning; hence the | |||||
| 3179 | # explicit call to the previous handler. | |||||
| 3180 | sub _warn_filter | |||||
| 3181 | { | |||||
| 3182 | 422 | 318 | my $skip = shift; | |||
| 3183 | 422 | 410 | my $prev = $SIG{__WARN__}; | |||
| 3184 | ||||||
| 3185 | return sub { | |||||
| 3186 | 24 | 82587 | return if $_[0] =~ $skip; | |||
| 3187 | 1 | 2 | if(ref($prev) eq 'CODE') { | |||
| 3188 | 1 | 2 | $prev->(@_); | |||
| 3189 | } else { | |||||
| 3190 | 0 | 0 | warn @_; | |||
| 3191 | } | |||||
| 3192 | 422 | 1235 | }; | |||
| 3193 | } | |||||
| 3194 | ||||||
| 3195 | # ââ Pure-Perl IP-validation helpers ââââââââââââââââââââââââââââââââââââââââââ | |||||
| 3196 | # These are installed into the CGI::Lingua symbol table as is_ipv4() etc. when | |||||
| 3197 | # Data::Validate::IP / NetAddr::IP are unavailable (see country()). | |||||
| 3198 | ||||||
| 3199 | sub _is_ipv4 { | |||||
| 3200 | 255 | 17988 | my $ip = shift; | |||
| 3201 | # /a: without it \d also matches non-ASCII digits such as U+0661, which | |||||
| 3202 | # then compare as 0 and make "\x{661}.\x{661}.\x{661}.\x{661}" look valid | |||||
| 3203 | 255 | 720 | return unless defined($ip) && $ip =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/a; | |||
| 3204 | 227 908 | 1297 2091 | return !(grep { $_ > 255 } ($1, $2, $3, $4)); | |||
| 3205 | } | |||||
| 3206 | ||||||
| 3207 | sub _is_ipv6 { | |||||
| 3208 | 21 | 5332 | my $ip = shift; | |||
| 3209 | 21 | 53 | return unless defined($ip) && $ip =~ /:/; | |||
| 3210 | # Socket::inet_pton is core since Perl 5.14 and validates IPv6 reliably | |||||
| 3211 | 17 | 23 | require Socket; | |||
| 3212 | 17 | 22 | if(defined &Socket::inet_pton) { | |||
| 3213 | 11 | 53 | return defined Socket::inet_pton(Socket::AF_INET6(), $ip); | |||
| 3214 | } | |||||
| 3215 | # Structural fallback: hex+colons, at least two colons, at most one :: | |||||
| 3216 | # (tr/// counts characters, not "::" pairs, so count matches of /::/ instead) | |||||
| 3217 | 6 | 28 | return ($ip =~ /^[0-9a-fA-F:]+\z/ || $ip =~ /^[0-9a-fA-F:]+:\d{1,3}(?:\.\d{1,3}){3}\z/a) | |||
| 3218 | && (() = $ip =~ /::/g) <= 1 | |||||
| 3219 | && ($ip =~ tr/://) >= 2; | |||||
| 3220 | } | |||||
| 3221 | ||||||
| 3222 | sub _is_private_ip { | |||||
| 3223 | 236 | 3745 | my $ip = shift; | |||
| 3224 | 236 | 220 | return unless defined($ip); | |||
| 3225 | 235 | 443 | if($ip =~ /^(\d+)\.(\d+)/) { | |||
| 3226 | 229 | 312 | my ($a, $b) = ($1 + 0, $2 + 0); | |||
| 3227 | 229 | 238 | return 1 if $a == 10; | |||
| 3228 | 224 | 273 | return 1 if $a == 172 && $b >= 16 && $b <= 31; | |||
| 3229 | 221 | 767 | return 1 if $a == 192 && $b == 168; | |||
| 3230 | 214 | 252 | return 1 if $a == 169 && $b == 254; # link-local / APIPA | |||
| 3231 | 213 | 269 | return 0; | |||
| 3232 | } | |||||
| 3233 | 6 | 10 | return 1 if $ip =~ /^fe[89ab][0-9a-f]:/i; # fe80::/10 link-local | |||
| 3234 | 5 | 7 | return 1 if $ip =~ /^f[cd]/i; # fc00::/7 ULA | |||
| 3235 | 4 | 5 | return 0; | |||
| 3236 | } | |||||
| 3237 | ||||||
| 3238 | sub _is_loopback_ip { | |||||
| 3239 | 220 | 1905 | my $ip = shift; | |||
| 3240 | 220 | 224 | return unless defined($ip); | |||
| 3241 | 219 | 208 | return 1 if $ip eq '::1'; | |||
| 3242 | 217 | 315 | return $ip =~ /^127\./; | |||
| 3243 | } | |||||
| 3244 | ||||||
| 3245 - 3936 | =head1 COMMON PITFALLS
=over 4
=item * B<Configuration files and environment variables win over your arguments>
L</new> passes its arguments to L<Object::Configure>.
If a configuration file (C<config_file>) or an environment variable such as
C<CGI__Lingua__supported> sets a value, that value B<replaces> the value you
gave to C<new()>.
Values are replaced, not merged. If you pass C<< supported => ['en', 'fr'] >>
and the configuration file says C<supported: [de]>, the object supports only
C<de>, not C<en>, C<fr> and C<de>.
For nested values (hashes inside hashes) see the rules in L<Object::Configure>.
=item * B<A copy made with C<< $object->new() >> keeps old answers>
When you call C<new()> on an object, the copy starts with every answer that the
original has already worked out. So if the original has already called
L</language>, the copy returns the same language,
even if you give the copy a different C<supported> list:
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
print $l->language(); # "French"
my $copy = $l->new(supported => ['en']);
print $copy->language(); # still "French"
Make the copy before you call any other method, or create a new object with
C<< CGI::Lingua->new() >>.
=item * B<'Unknown' is not the same as undef>
Some methods return the B<string> C<'Unknown'> and some return C<undef>
when they do not know the answer. C<'Unknown'> is a true value in Perl,
so C<if($l-E<gt>language())> is always true.
Method When it does not know
------------------------ -----------------------------
language() 'Unknown' (never undef)
requested_language() 'Unknown' (never undef)
sublanguage() undef
language_code_alpha2() undef
sublanguage_code_alpha2() undef
country() undef ('Unknown' for EU addresses)
locale() undef
time_zone() undef
translation_file() undef
is_rtl() 0
text_direction() 'ltr'
plural_category() 'other'
Always test with C<eq 'Unknown'> or C<defined()>, as the table shows.
C<undef> really is one value, even in list context, so it is safe to build a
hash from the results:
my %vars = (country => $l->country(), zone => $l->time_zone());
# $vars{zone} is the time zone even when country() is undef
=item * B<plural_category() returns 'other' when there is no language>
When no supported language was found, C<plural_category(1)> returns
C<'other'>, not C<'one'>. Have an C<'other'> form for every message.
C<plural_category(undef)> dies.
=item * B<C<supported =E<gt> 0> and C<supported =E<gt> ''> count as missing>
A false value for C<supported> is treated as if you did not give it,
so C<new()> dies with "You must give a list of supported languages".
=item * B<Answers are worked out once, then remembered>
The first call to a language method reads C<%ENV> and remembers the answer.
If you change C<%ENV> afterwards, the object does not notice.
Create a new object for each web request.
=item * B<country() reads REMOTE_ADDR when you call it>
C<country()> reads C<$ENV{REMOTE_ADDR}> when you B<call> it,
not when you create the object. In tests, call C<country()> while
C<REMOTE_ADDR> still has the value you want:
{
local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $l = CGI::Lingua->new(supported => ['en']);
$country = $l->country(); # correct: inside the block
}
=item * B<A bad header is ignored completely>
If C<HTTP_ACCEPT_LANGUAGE> is longer than 256 characters, or contains a
character that is not allowed (see L</ENCODING>), the whole header is ignored
with a warning. CGI::Lingua then uses C<LANG> or the IP address instead.
=item * B<The supported list holds tags, not names>
C<< supported => ['english', 'fr'] >> supports only French: C<'english'> is not a
language tag, so it is dropped (with a warning). Use C<'en'>.
=item * B<Language names are in English>
L</language> returns C<'French'>, not C<'Francais'>, and C<'German'>, not
C<'Deutsch'>. Use L</language_code_alpha2> if you want to show the name in
the language itself.
=item * B<The logger you pass is replaced>
L<Object::Configure> always changes C<logger> into a new L<Log::Abstraction>
object, so C<< $l->{logger} >> is not the object you passed.
=item * B<Whole objects are only cached when REMOTE_ADDR is set>
The answers of an object are read from the cache in L</new>, and written to it
when the object is destroyed, only when C<REMOTE_ADDR> is set.
On the command line, only smaller look-ups (such as code-to-name) are cached.
=item * B<Local geo databases can be out of date>
Old F<GeoIP.dat> files can give the wrong country for some addresses
(see L</country>).
=back
=head1 ENCODING
CGI::Lingua works with ASCII text. This table shows what each input accepts.
Input Accepted characters Non-ASCII / UTF-8 / emoji
---------------------- ------------------------- -------------------------
supported language tags (en, en-gb, no: entry is ignored,
es-419); a plain string with a warning
must be 2-5 chars
HTTP_ACCEPT_LANGUAGE A-Z a-z 0-9 - , ; = . * no: header is ignored
space and tab (never CR or
LF); max 256 chars
lang (CGI::Info) as HTTP_ACCEPT_LANGUAGE no: parameter is ignored
LANG A-Z a-z 0-9 _ . - no: LANG is ignored
HTTP_USER_AGENT printable ASCII no: user agent ignored
(0x20-0x7E), max 512 chars
REMOTE_ADDR IPv4 or IPv6 address no: address is rejected
GEOIP_COUNTRY_CODE exactly two of A-Z no: value is ignored
HTTP_CF_IPCOUNTRY exactly two of A-Z no: value is ignored
translation_file $dir any, except ".." and NUL yes, if your file system
supports it (pass bytes
encoded as the file
system expects)
translation_file $ext A-Z a-z 0-9 - no: undef is returned
plural_category $n a number not applicable
All values that CGI::Lingua returns are plain ASCII: language names
(C<'French'>), country names (C<'Reunion'>), codes and time zone names.
You do not need to decode them.
=head1 CONFIGURATION VARIABLES
Two package variables say where CGI::Lingua looks for files on the local
machine. You do not normally need to change them; tests and unusual
installations can, with C<local>:
=over 4
=item * C<$CGI::Lingua::ZONE_FILE>
The file that L</time_zone> reads when there is no C<REMOTE_ADDR> (command-line
use). The default is F</etc/timezone>. Only a readable regular file is used,
and only its first 256 bytes are read; otherwise L<DateTime::TimeZone> is used.
local $CGI::Lingua::ZONE_FILE = '/srv/myapp/timezone';
=item * C<@CGI::Lingua::GEOIP_DAT>
The places where L</country> and L</time_zone> look for the legacy MaxMind
F<GeoIP.dat> used by L<Geo::IP>; the first readable regular file is used. The
default is F</usr/share/GeoIP/GeoIP.dat> and F</usr/local/share/GeoIP/GeoIP.dat>
(and F<c:/GeoIP/GeoIP.dat> first, on Windows). A file that L<Geo::IP> cannot
open is skipped with the warning C<"Can't open ... with Geo::IP; not using it">.
local @CGI::Lingua::GEOIP_DAT = ('/opt/geo/GeoIP.dat');
=back
=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<The logger is always a Log::Abstraction object>
The C<logger> argument can be an object with C<warn()>, C<info()> and
C<error()> methods, or any value that L<Object::Configure> accepts (such as an
array reference or a hash reference of options). In every case
L<Object::Configure> replaces it with a L<Log::Abstraction> object. An object
that is missing one of the three methods is rejected by L</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
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.
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.
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.
=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
This section describes each method in the Z notation.
You do not need it to use the module.
=head2 Types and state
[LANGTAG, LANGNAME, CNAME, IPADDR, PATH, ZONE, LOCALE]
CC2 == { s : seq CHAR | #s = 2 â§ ran s â 'a'..'z' }
LC2 == CC2
PLURAL ::= zero | one | two | few | many | other
GEO ::= unknown | absent | present
DIR ::= ltr | rtl
RTL_LANGS == { ar, dv, fa, he, ku, ps, sd, ug, ur, yi }
⥠marks a value that has not been worked out yet (or is undef).
ââ Lingua ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â supported : seq LANGTAG
â slanguage : LANGNAME ⪠{Unknown, â¥}
â rlanguage : LANGNAME ⪠{Unknown, â¥}
â code2 : LC2 ⪠{â¥}
â subcode2 : CC2 ⪠{â¥}
â sublanguage : CNAME ⪠{â¥}
â country : CC2 ⪠{Unknown, â¥}
â locale : LOCALE ⪠{â¥}
â timezone : ZONE ⪠{â¥}
â ipc, gip, gipf : GEO
â dont_use_ip : ð¹
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â slanguage â {Unknown, â¥} â
â code2 â ⥠⧠(â t : ran supported ⢠base(t) = code2)
â slanguage = Unknown â code2 = ⥠⧠subcode2 = â¥
â subcode2 â ⥠â code2 â â¥
â sublanguage â ⥠â subcode2 â â¥
â (slanguage = â¥) â (rlanguage = â¥)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
request : ENV ⸠seq (LANGTAG à â) -- lang param, Accept-Language or LANG
negotiate : seq (LANGTAG à â) à seq LANGTAG ⸠LANGTAG
geo : IPADDR ⸠CC2 -- first answer of the geo sources
official : CC2 ⸠LANGTAG -- official language of a country
ââ Resolve âââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLingua
â env? : ENV
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â slanguage = â¥
â let t == negotiate(request(env?), supported) â¢
â t â ⥠â slanguage' = name(base(t)) â§ code2' = base(t)
â â§ subcode2' = variety(t)
â t = ⥠⧠¬ dont_use_ip â§ official(geo(env?.REMOTE_ADDR)) â ran supported
â â slanguage' = name(official(geo(env?.REMOTE_ADDR)))
â otherwise slanguage' = Unknown
â rlanguage' â â¥
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
EnsureResolved â (Resolve ⨠(ÎLingua â§ slanguage â â¥))
=head2 new
ââ New âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Lingua'
â supported? : LANGTAG ⪠seq LANGTAG
â cache? : CACHE ⪠{â¥}
â env? : ENV
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â supported? â LANGTAG â 2 ⤠#supported? ⤠5
â supported' = (if supported? â LANGTAG then â¨supported?â© else supported?)
â â¾ SUPPORTED_TAGS -- other entries dropped, with a warning
â ipc' = gip' = gipf' = unknown
â dont_use_ip' = dont_use_ip? -- never taken from the cache
â let ip == untaint_ip(env?.REMOTE_ADDR); saved == decode(cache?(key(ip))) â¢
â (cache? â ⥠⧠ip â ⥠⧠key(ip) â dom cache?
â â§ (â f : dom saved â© dom RESTORABLE ⢠saved(f) â RESTORABLE(f)))
â â (â f : dom saved â© dom RESTORABLE ⢠θLingua'.f = saved(f))
â (cache? â ⥠⧠ip â ⥠⧠key(ip) â dom cache?
â â§ (â f : dom saved â© dom RESTORABLE ⢠saved(f) â RESTORABLE(f)))
â â key(ip) â dom cache?' -- poisoned entry removed, with a warning
â otherwise
â slanguage' = rlanguage' = country' = locale' = timezone' = â¥
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
RESTORABLE : FIELD ⸠â seq CHAR
RESTORABLE â { slanguage, rlanguage, sublanguage ⦠NAME,
slanguage_code_alpha2, sublanguage_code_alpha2 ⦠LANGCODE,
country ⦠CC2 ⪠{Unknown} }
NewError â [ supported? = ⥠⨠supported? â LANGTAG ⪠seq LANGTAG
⨠(info? â ⥠⧠¬ can(info?, lang) ⧠¬ can(info?, AUTOLOAD)) ] â croak
=head2 language
ââ Language ââââââââââââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : LANGNAME ⪠{Unknown}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = slanguage'
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
PreferredLanguage â Language
Name â Language
=head2 sublanguage
ââ Sublanguage âââââââââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : CNAME ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = sublanguage'
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 language_code_alpha2
ââ LanguageCodeAlpha2 ââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : LC2 ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = code2'
â slanguage' = Unknown â result! = â¥
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
CodeAlpha2 â LanguageCodeAlpha2
=head2 sublanguage_code_alpha2
ââ SublanguageCodeAlpha2 âââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : CC2 ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = subcode2'
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 requested_language
ââ RequestedLanguage âââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : seq CHAR
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = rlanguage'
â -- rlanguage' = name(b) â " (" â cname(v) â ")" when the visitor
â -- asked for base b with variety v; name(b) when no variety;
â -- Unknown when nothing was asked for.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 country
ââ Country âââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLingua
â env? : ENV
â result! : CC2 ⪠{Unknown, â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â country â ⥠â result! = country ⧠θLingua' = θLingua
â country = ⥠â
â ( env?.GEOIP_COUNTRY_CODE â CC2â â result! = lc(env?.GEOIP_COUNTRY_CODE) )
â ( env?.HTTP_CF_IPCOUNTRY â CC2â \ {XX} â result! = lc(env?.HTTP_CF_IPCOUNTRY) )
â ( ip = canon(env?.REMOTE_ADDR) â PUBLIC_IP â result! = norm(geo(ip)) )
â ( ip â PUBLIC_IP â result! = ⥠)
â country' = result!
â ipc' â unknown ⨠ipc' = ipc ; gip' â unknown ⨠gip' = gip
â -- norm maps hk ⦠cn, eu ⦠Unknown (or cn for the Baidu subnet),
â -- and anything â CC2 ⦠â¥
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
where CC2â is CC2 in upper case, and canon(::ffff:a.b.c.d) = a.b.c.d.
=head2 locale
ââ Locale ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLingua
â env? : ENV
â result! : LOCALE ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â locale â ⥠â result! = locale ⧠θLingua' = θLingua
â locale = ⥠â result! = first â¥-free of
â ⨠uatag(env?.HTTP_USER_AGENT), browserdetect(env?.HTTP_USER_AGENT),
â loc(Country.result!), loc(env?.GEOIP_COUNTRY_CODE) â©
â result! â ⥠â locale' = result!
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 time_zone
ââ TimeZone ââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ÎLingua
â env? : ENV
â result! : ZONE ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â timezone â ⥠â result! = timezone
â timezone = ⥠⧠env?.REMOTE_ADDR â ⥠â
â result! = first â¥-free of ⨠geoip_tz(ip), ipapi_tz(ip) â©
â timezone = ⥠⧠env?.REMOTE_ADDR = ⥠â
â result! = first â¥-free of ⨠etc_timezone, local_tz â©
â result! â ZONE â result! = â¥
â timezone' = result!
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 is_rtl
ââ IsRtl âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â result! : {0, 1}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â result! = 1 â code2' â RTL_LANGS
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 text_direction
ââ TextDirection âââââââââââââââââââââââââââââââââââââââââââââââ
â IsRtl
â dir! : DIR
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â dir! = (if result! = 1 then rtl else ltr)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 plural_category
PLURAL_RULES : LC2 ⸠(â â PLURAL)
english == λ n : â ⢠(if n = 1 then one else other)
ââ PluralCategory ââââââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â n? : â
â result! : PLURAL
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â n? â â¥
â code2' = ⥠â result! = other
â code2' â dom PLURAL_RULES â result! = PLURAL_RULES(code2')(trunc n?)
â code2' â dom PLURAL_RULES ⪠{â¥} â result! = english(trunc n?)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
PluralError â [ n? = ⥠] â croak
=head2 translation_file
ââ TranslationFile âââââââââââââââââââââââââââââââââââââââââââââ
â EnsureResolved
â dir? : PATH ⪠{â¥}
â ext? : seq CHAR ⪠{â¥}
â files : â PATH -- readable regular files
â result! : PATH ⪠{â¥}
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â e == (if ext? = ⥠then "json" else strip_dot(ext?))
â (dir? = ⥠⨠dir? = â¨â© ⨠".." â dir? ⨠NUL â ran dir?
â ⨠¬ (ran e â ALNUM ⪠{'-'})) â result! = â¥
â otherwise
â cands == ⨠code2' â "-" â subcode2' | subcode2' â ⥠⩠â ⨠code2' | code2' â ⥠â©
â result! = first p : ran cands ⢠dir? â "/" â p â "." â e â files
â (⥠if there is none)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head1 STATE DIAGRAM
A CGI::Lingua object has several independent parts. Each part starts
empty and is filled in the first time a method needs it.
After that, the part does not change (it is remembered).
Part 1: the language (used by language(), sublanguage(),
language_code_alpha2(), sublanguage_code_alpha2(), requested_language(),
is_rtl(), text_direction(), plural_category(), translation_file())
new()
|
v
+---------------------------+
| LANGUAGE NOT CHECKED |
| _slanguage = undef |
+---------------------------+
|
first call to any language method:
read the lang param, HTTP_ACCEPT_LANGUAGE or LANG,
and compare it with the supported list
|
+-----------------+------------------+
| |
a match no match
| |
| +-------------+-------------+
| | |
| dont_use_ip is false dont_use_ip
| side effect: country() is true
| is called (Part 2), and |
| the country's official |
| language is tried |
| | | |
| supported not supported |
v v v v
+----------------------------+ +------------------------------+
| MATCHED | | UNMATCHED |
| language() = 'English' ... | | language() = 'Unknown' |
| language_code_alpha2() set | | language_code_alpha2() undef |
+----------------------------+ +------------------------------+
Both MATCHED and UNMATCHED are final: later calls return the same
answers, even if %ENV changes.
Part 2: the country (used by country(), locale(), and the language IP path)
+----------------------+ country(): REMOTE_ADDR missing, invalid,
| COUNTRY NOT KNOWN |---------------------------------------------+
| _country = undef | private or loopback, or no source answers |
+----------------------+<--------------------------------------------+
| (returns undef; NOT remembered, so the
| next call tries again)
| country(): a source gives a valid code
| side effects: geo modules loaded, sentinels set,
| value stored in cache (if any)
v
+----------------------+
| COUNTRY KNOWN | later calls return the same value
| _country = 'xx' | (or 'Unknown' for EU addresses)
+----------------------+
Part 3: each geo module (IP::Country, Geo::IP, Geo::IPfree)
+-----------+ first use: module loads +-----------+
| UNKNOWN |--------------------------->| PRESENT |
| (-1) | | (1) |
+-----------+--------------------------->+-----------+
first use: module or +-----------+
database is missing ----->| ABSENT |
| (0) |
+-----------+
Part 4: locale() and time_zone()
+------------+ locale() / time_zone() finds a value +------------+
| NOT KNOWN |--------------------------------------->| KNOWN |
| (undef) |<-------+ | remembered |
+------------+ | nothing found: returns undef, +------------+
| | stays NOT KNOWN (time_zone()
+---------------+ also warns)
Part 5: the life of the object
new() ----------------------> ALIVE ----------------------> DESTROYED
| no cache entry, or ^ object goes out of side effect:
| no REMOTE_ADDR | scope (DESTROY) if cache and
| | REMOTE_ADDR are
+---- valid cache entry ------+ set and no entry
| found (the six answer | exists yet, the
| fields restored, each | answers are saved
| checked; geo sentinels | to the cache as
| reset to UNKNOWN) | JSON
| |
+---- malformed cache entry --+
| found (removed, warned;
| answers worked out again)
|
+---- REMOTE_ADDR not a valid address: the cache is not used
|
+---- bad arguments ---> croak (no object)
$obj->new(...) makes a copy in the ALIVE state that keeps all the
answers of $obj (all parts keep their current state).
=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 | |||||
| 3937 | ||||||
| 3938 | 1; # End of CGI::Lingua | |||||