File Coverage

File:blib/lib/CGI/Lingua.pm
Coverage:80.8%

linestmtbrancondsubtimecode
1package CGI::Lingua;
2
3
28
28
28
2027557
21
565
use warnings;
4
28
28
28
47
17
275
use strict;
5
28
28
28
3055
106393
49
use autodie qw(:all);
6
7
28
28
28
150080
38
712
use Carp qw(croak carp);
8
28
28
28
7089
1728337
528
use Object::Configure 0.23;
9
28
28
28
62
131
422
use Params::Get 0.15;   # 0.15 fast-path: unblessed hashref returned directly
10
28
28
28
48
26
449
use Readonly;
11
28
28
28
51
26
371
use Scalar::Util qw(blessed);
12
28
28
28
8549
163631
458
use JSON::PP ();
13
28
78
use Class::Autouse qw{
14        Locale::Language
15        Locale::Object::Country
16        Locale::Object::DB
17        I18N::AcceptLanguage
18        I18N::LangTags::Detect
19
28
28
6207
85555
};
20
21our $VERSION = '0.84';
22
23# ── Module-level constants ───────────────────────────────────────────────────
24# Gathering magic strings here makes behavioural changes one-edit operations.
25
26Readonly my $CACHE_TTL_LONG      => '1 month';
27Readonly my $CACHE_TTL_SHORT     => '1 hour';
28Readonly my $CACHE_NS            => 'CGI::Lingua:';    # namespace prefix for every key
29Readonly my $BROKEN_GEOIPFREE    => '45.128.139.41';  # https://github.com/bricas/geo-ipfree/issues/10
30Readonly my $BAIDU_SUBNET        => '185.10.104.0/22';# RT-86809: Baidu misreports as EU
31Readonly my $DEPRECATED_EN_UK    => 'en-uk';          # some browsers still send this
32Readonly my $CANONICAL_EN_GB     => 'en-gb';
33Readonly my $ACCEPT_LANG_MAX     => 256;              # max bytes we accept from the header
34Readonly my $UA_MAX              => 512;              # max bytes we accept from HTTP_USER_AGENT
35Readonly my $GEO_UNKNOWN         => -1;               # geo-module sentinel: not yet probed
36Readonly my $GEO_ABSENT          =>  0;               # geo-module sentinel: unavailable
37Readonly my $GEO_PRESENT         =>  1;               # geo-module sentinel: loaded OK
38Readonly my %RTL_LANGS           => (map { $_ => 1 }  # ISO 639-1 codes whose primary script is RTL
39        qw(ar dv fa he ku ps sd ug ur yi));
40
41 - 49
=head1 NAME

CGI::Lingua - Create a multilingual web page

=head1 VERSION

Version 0.84

=cut
50
51 - 146
=head1 SYNOPSIS

CGI::Lingua is a powerful module for multilingual web applications
offering extensive language/country detection strategies.

No longer does your website need to be in English only.
CGI::Lingua provides a simple basis to determine which language to display a website.
The website tells CGI::Lingua which languages it supports.
Based on that list CGI::Lingua tells the application which language the user would like to use.

    use CGI::Lingua;
    # ...
    my $l = CGI::Lingua->new(['en', 'fr', 'en-gb', 'en-us']);
    my $language = $l->language();
    if ($language eq 'English') {
        print '<P>Hello</P>';
    } elsif($language eq 'French') {
        print '<P>Bonjour</P>';
    } else {    # $language eq 'Unknown'
        my $rl = $l->requested_language();
        print "<P>Sorry for now this page is not available in $rl.</P>";
    }
    my $c = $l->country();
    if ($c eq 'us') {
      # print contact details in the US
    } elsif ($c eq 'ca') {
      # print contact details in Canada
    } else {
      # print worldwide contact details
    }

    # ...

    use CHI;
    use CGI::Lingua;
    # ...
    my $cache = CHI->new(driver => 'File', root_dir => '/tmp/cache', namespace => 'CGI::Lingua-countries');
    $l = CGI::Lingua->new({ supported => ['en', 'fr'], cache => $cache });

=head1 SUBROUTINES/METHODS

=head2 new

Creates a CGI::Lingua object.

=head3 API SPECIFICATION

    Input:
      supported  => ArrayRef[Str] | Str   # required; RFC-1766 language codes
      cache      => Object                # optional; CHI-compatible (get/set)
      config_file => Str                  # optional; YAML/XML/INI config path
      logger     => Object                # optional; must implement warn/info/error
      info       => Object                # optional; CGI::Info-compatible
      data       => Any                   # optional; forwarded to I18N::AcceptLanguage
      dont_use_ip => Bool                 # optional; disable IP-based fallback
      syslog     => Bool | HashRef        # optional; Sys::Syslog integration
      debug      => Bool                  # optional; enable debug logging

    Returns: CGI::Lingua blessed hashref, or a clone when called on an object.

=head3 EXAMPLE

    # Array-ref of supported codes (most common form)
    my $l = CGI::Lingua->new({ supported => ['en', 'fr', 'de'] });

    # Single scalar code
    my $l = CGI::Lingua->new(supported => 'en');

    # With cache, logger, and CGI::Info object
    use CHI;
    my $cache = CHI->new(driver => 'File', root_dir => '/tmp/lingua-cache');
    my $l = CGI::Lingua->new({
        supported => ['en', 'fr'],
        cache     => $cache,
        logger    => $my_log_object,
    });

    # Clone an existing object with different supported list
    my $clone = $l->new(supported => ['de']);

=head3 MESSAGES

    "You must give a list of supported languages"  - no 'supported' key provided
    "List of supported languages must be an array ref" - supported is wrong ref type
    "Supported languages must be the short code"  - string too short or too long
    "Logger must be a blessed object with warn/info/error methods" - bad logger arg

=head3 PSEUDOCODE

    1. Normalise args via Params::Get and Object::Configure
    2. Validate logger (must be blessed with warn/info/error) if provided
    3. Validate supported (required, string or arrayref)
    4. If cache and REMOTE_ADDR set, attempt to thaw a previously stored state
    5. Bless and return fresh object with sentinel flags set to GEO_UNKNOWN

=cut
147
148sub new
149{
150
1699
3726370
        my $class = shift;
151
1699
2124
        my $params = Params::Get::get_params('supported', @_);
152
153        # Handle ::new() misuse
154
1695
24455
        if(!defined($class)) {
155
2
3
                if($params) {
156
2
2
                        if(my $logger = $params->{'logger'}) {
157
0
0
                                $logger->error(__PACKAGE__ . ' use ->new() not ::new() to instantiate');
158                        }
159
2
10
                        croak(__PACKAGE__ . ' use ->new() not ::new() to instantiate');
160                }
161
0
0
                $class = __PACKAGE__;
162        } elsif(ref($class)) {
163                # Clone: overlay new params onto existing object state
164
3
11
                $params->{_supported} ||= $params->{supported} if defined $params->{'supported'};
165
3
3
3
2
5
10
                return bless { %{$class}, %{$params} }, ref($class);
166        }
167
168        # Validate blessed logger objects before Object::Configure runs.
169        # Non-blessed values (arrayrefs, hashrefs) are valid config forms that
170        # Object::Configure knows how to convert into a Log::Abstraction instance.
171
1690
1791
        if(defined $params->{'logger'} && blessed($params->{'logger'})) {
172
7
24
                unless(
173                        $params->{'logger'}->can('warn')
174                        && $params->{'logger'}->can('info')
175                        && $params->{'logger'}->can('error')
176                ) {
177
3
160
                        croak('Logger must be a blessed object with warn/info/error methods');
178                }
179        }
180
181
1687
2332
        $params = Object::Configure::configure($class, $params);
182
183        # Normalise supported / supported_languages alias
184
1687
3128694
        $params->{'supported'} ||= $params->{'supported_languages'};
185
1687
2200
        if(defined($params->{supported})) {
186                # Validate supported type/length
187
1681
2895
                if(ref($params->{supported})) {
188
650
792
                        if(ref($params->{supported}) ne 'ARRAY') {
189
243
2751
                                croak('List of supported languages must be an array ref');
190                        }
191                } elsif((length($params->{supported}) < 2) || (length($params->{supported}) > 5)) {
192
185
2118
                        croak('Supported languages must be the short code');
193                }
194        } else {
195
6
10
                if(my $logger = $params->{'logger'}) {
196
6
9
                        $logger->error('You must give a list of supported languages');
197                }
198
6
1777
                croak('You must give a list of supported languages');
199        }
200
201
1253
829
        my $cache = $params->{cache};
202
1253
849
        my $info  = $params->{info};
203
204        # Try to restore a frozen state from the cache before doing any work
205
1253
1082
        if($cache && $ENV{'REMOTE_ADDR'}) {
206
33
45
                my $key = _build_cache_key($ENV{'REMOTE_ADDR'}, $params, $class, $info);
207
33
88
                if(my $frozen = $cache->get($key)) {
208                        # JSON::PP is used in preference to Storable::thaw because Storable
209                        # can execute arbitrary Perl code via STORABLE_thaw hooks if an
210                        # attacker manages to write a crafted blob to the cache backend.
211                        # JSON cannot execute code regardless of its content.
212                        # If the blob is not valid JSON (e.g. a legacy Storable entry), the
213                        # eval catches the error and we fall through to fresh construction.
214
6
6
6
459
9
13
                        my $rc = eval { local $SIG{__DIE__}; JSON::PP::decode_json($frozen) };
215
6
3246
                        unless(defined $rc && ref($rc) eq 'HASH') {
216
0
0
                                $rc = undef;    # stale or corrupt entry — rebuild below
217                        }
218
6
7
                        if(defined $rc) {
219
6
6
                                bless $rc, $class;
220                                # Re-inject transient/non-serialisable fields
221
6
9
                                $rc->{logger}           = $params->{'logger'};
222
6
7
                        $rc->{_syslog}          = $params->{syslog};
223
6
6
                        $rc->{_cache}           = $cache;
224
6
6
                        $rc->{_supported}       = $params->{supported};
225
6
6
                        $rc->{_info}            = $info;
226
6
11
                        $rc->{_have_ipcountry}  = $GEO_UNKNOWN;
227
6
17
                        $rc->{_have_geoip}      = $GEO_UNKNOWN;
228
6
12
                        $rc->{_have_geoipfree}  = $GEO_UNKNOWN;
229
230                        # If lang= CGI param is active, the cached language choice may be stale
231
6
28
                                if(($rc->{_what_language} || $rc->{_rlanguage}) && $info && $info->lang()) {
232
0
0
0
0
                                        delete @{$rc}{qw(_what_language _rlanguage _country)};
233                                }
234
6
16
                                return $rc;
235                        }
236                }
237        }
238
239        return bless {
240
1247
6290
                %{$params},
241                _supported => ref($params->{supported}) ? $params->{supported} : [ $params->{'supported'} ],
242                _cache           => $cache,
243                _info            => $info,
244                _syslog          => $params->{syslog},
245                _dont_use_ip     => $params->{dont_use_ip} || 0,
246                _have_ipcountry  => $GEO_UNKNOWN,
247                _have_geoip      => $GEO_UNKNOWN,
248                _have_geoipfree  => $GEO_UNKNOWN,
249
1247
1867
                _debug           => $params->{debug} || 0,
250        }, $class;
251}
252
253# ── _build_cache_key ──────────────────────────────────────────────────────────
254# Purpose:      Produce a deterministic string key for the per-request cache
255#               entry stored in new() and DESTROY().
256# Entry:        $addr  â€” IP string (not yet taint-checked; used read-only here)
257#               $params — constructor params hashref
258#               $class  â€” package name
259#               $info   â€” optional CGI::Info object
260# Exit:         A plain string key of the form "ip/lang/lang1/lang2/..."
261sub _build_cache_key
262{
263
74
51959
        my ($addr, $params, $class, $info) = @_;
264
265
74
84
        my $key = "$addr/";
266
267        # Include the requested language (if determinable) so different
268        # Accept-Language values get distinct cache slots for the same IP.
269
74
46
        my $l;
270
74
155
        if($info && ($l = $info->lang())) {
271
3
7
                $key .= "$l/";
272        } elsif($l = $class->_what_language()) {
273
46
39
                $key .= "$l/";
274        }
275
276        # Fix: was ref($params->{'supported'} eq 'ARRAY') — eq was inside ref(),
277        # so ref() always received a boolean (1 or ''), never the arrayref itself.
278        # Result: arrayref-supported always fell through to the else branch and
279        # stringified to 'ARRAY(0x...)' — a different address every request —
280        # making cache lookups in new() permanently fail.
281
74
101
        if(ref($params->{'supported'}) eq 'ARRAY') {
282
73
73
49
87
                $key .= join('/', @{$params->{supported}});
283        } else {
284
1
1
                $key .= $params->{'supported'};
285        }
286
287
74
88
        return $key;
288}
289
290# Some of the information takes a long time to work out, so cache what we can
291sub DESTROY {
292
1248
276339
        if(defined($^V) && ($^V ge 'v5.14.0')) {
293
1248
1654
                return if ${^GLOBAL_PHASE} eq 'DESTRUCT';
294        }
295
1248
5087
        return unless $ENV{'REMOTE_ADDR'};
296
297
188
127
        my $self = shift;
298
188
179
        return unless ref($self);
299
300
188
285
        my $cache = $self->{_cache};
301
188
537
        return unless $cache;
302
303        my $key = _build_cache_key(
304                $ENV{'REMOTE_ADDR'},
305                { supported => $self->{_supported} },
306                ref($self),
307                $self->{_info},
308
34
77
        );
309
34
65
        return if $cache->get($key);
310
311
28
1068
        $self->_debug("Storing self in cache as $key");
312
313        # Serialise only the computed state — not loggers, file handles, or
314        # geo-module objects (they are re-initialised on next construction).
315        # JSON::PP is used instead of Storable so that a compromised cache backend
316        # cannot deliver a blob that executes code via STORABLE_thaw hooks.
317        my %state = (
318                _slanguage               => $self->{_slanguage},
319                _slanguage_code_alpha2   => $self->{_slanguage_code_alpha2},
320                _sublanguage_code_alpha2 => $self->{_sublanguage_code_alpha2},
321                _country                 => $self->{_country},
322                _rlanguage               => $self->{_rlanguage},
323                _dont_use_ip             => $self->{_dont_use_ip},
324                _have_ipcountry          => $self->{_have_ipcountry},
325                _have_geoip              => $self->{_have_geoip},
326                _have_geoipfree          => $self->{_have_geoipfree},
327
28
1034
        );
328
329
28
60
        $cache->set($key, JSON::PP::encode_json(\%state), $CACHE_TTL_LONG);
330}
331
332 - 354
=head2 language

Tells the CGI application in what language to display its messages.
The language is the natural name e.g. 'English' or 'Japanese'.

Sublanguages are handled sensibly, so that if a client requests U.S. English
on a site that only serves British English, language() will return 'English'.

If none of the requested languages is included within the supported lists,
language() returns 'Unknown'.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'fr,en;q=0.9';
    my $l = CGI::Lingua->new(supported => ['en', 'fr']);
    print $l->language();   # "French"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str - human-readable language name, or 'Unknown'

=cut
355
356sub language {
357
163
6429
        my $self = $_[0];
358
359
163
293
        $self->_find_language() unless $self->{_slanguage};
360
163
913
        return $self->{_slanguage};
361}
362
363 - 367
=head2 preferred_language

Same as language().

=cut
368
369sub preferred_language
370{
371
7
23
        my $self = shift;
372
7
11
        return $self->language(@_);
373}
374
375 - 379
=head2 name

Synonym for language, for compatibility with Locale::Object::Language.

=cut
380
381sub name {
382
7
23
        my $self = $_[0];
383
7
11
        return $self->language();
384}
385
386 - 402
=head2 sublanguage

Tells the CGI what variant to use e.g. 'United Kingdom', or undef if
it can't be determined.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
    my $l = CGI::Lingua->new(supported => ['en-gb']);
    print $l->sublanguage();   # "United Kingdom"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str | undef

=cut
403
404sub sublanguage {
405
45
105
        my $self = $_[0];
406
407
45
62
        $self->_trace('Entered sublanguage');
408
45
1433
        $self->_find_language() unless $self->{_slanguage};
409
45
92
        $self->_trace('Leaving sublanguage ', ($self->{_sublanguage} || 'undef'));
410
45
1156
        return $self->{_sublanguage};
411}
412
413 - 432
=head2 language_code_alpha2

Gives the two-character representation of the supported language, e.g. 'en'
when you've asked for en-gb.

If none of the requested languages is included within the supported lists,
language_code_alpha2() returns undef.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
    my $l = CGI::Lingua->new(supported => ['en-gb']);
    print $l->language_code_alpha2();   # "en"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str (2 chars) | undef

=cut
433
434sub language_code_alpha2 {
435
61
77
        my $self = $_[0];
436
437
61
74
        $self->_trace('Entered language_code_alpha2');
438
61
2196
        $self->_find_language() unless $self->{_slanguage};
439
61
90
        $self->_trace('language_code_alpha2 returns ', $self->{_slanguage_code_alpha2});
440
61
1803
        return $self->{_slanguage_code_alpha2};
441}
442
443 - 447
=head2 code_alpha2

Synonym for language_code_alpha2, kept for historical reasons.

=cut
448
449sub code_alpha2 {
450
14
570
        my $self = $_[0];
451
14
15
        return $self->language_code_alpha2();
452}
453
454 - 470
=head2 sublanguage_code_alpha2

Gives the two-character representation of the supported language, e.g. 'gb'
when you've asked for en-gb, or undef.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
    my $l = CGI::Lingua->new(supported => ['en-gb']);
    print $l->sublanguage_code_alpha2();   # "gb"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str (2 chars) | undef

=cut
471
472sub sublanguage_code_alpha2 {
473
19
1114
        my $self = $_[0];
474
475
19
50
        $self->_find_language() unless $self->{_slanguage};
476
19
33
        return $self->{_sublanguage_code_alpha2};
477}
478
479 - 498
=head2 requested_language

Gives a human-readable rendition of what language the user asked for whether
or not it is supported.

Returns the sublanguage (if appropriate) in parentheses,
e.g. "English (United Kingdom)"

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
    my $l = CGI::Lingua->new(supported => ['en']);
    print $l->requested_language();   # "English (United Kingdom)"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str - e.g. "English (United Kingdom)" or "Unknown"

=cut
499
500sub requested_language {
501
67
9518
        my $self = $_[0];
502
503
67
113
        $self->_find_language() unless $self->{_rlanguage};
504
67
171
        return $self->{_rlanguage};
505}
506
507# ── _find_language ─────────────────────────────────────────────────────────
508# Purpose:      Populate _slanguage, _rlanguage, _sublanguage, and the
509#               various code fields by working through the detection pipeline:
510#               Accept-Language header → I18N::AcceptLanguage → IP country.
511# Entry:        $self->{_slanguage} must be undef (guards repeated calls).
512# Exit:         $self->{_slanguage} is set to a language name or 'Unknown'.
513# Side Effects: Populates _rlanguage, _sublanguage, *_code_alpha2 fields.
514sub _find_language
515{
516
183
1117
        my $self = shift;
517
518
183
229
        $self->_trace('Entered _find_language');
519
520
183
7395
        $self->{_rlanguage} = 'Unknown';
521
183
140
        $self->{_slanguage} = 'Unknown';
522
523
183
209
        my $http_accept_language = $self->_what_language();
524
183
568
        if(defined($http_accept_language)) {
525                $self->_debug(
526                        "language wanted: $http_accept_language, "
527                        . 'languages supported: '
528
148
148
134
345
                        . join(', ', @{$self->{_supported}} // '')
529                );
530
531                # Normalise the deprecated en-uk tag that some browsers send
532
148
4684
                if($http_accept_language eq $DEPRECATED_EN_UK) {
533
5
18
                        $self->_debug("Resetting country code to GB for $http_accept_language");
534
5
147
                        $http_accept_language = $CANONICAL_EN_GB;
535                }
536
537                # Run the header through the Accept-Language resolver
538
148
444
                my ($l, $requested_sublanguage) =
539                        $self->_accept_language_match($http_accept_language);
540
541                # Resolve the matched code to a full language/sublanguage
542
148
172
                if($l) {
543
125
145
                        return if $self->_resolve_match($l, $requested_sublanguage, $http_accept_language);
544                } elsif($http_accept_language =~ /;/) {
545                        # e.g. de-DE,de;q=0.9,en-US;q=0.8 and we support none of those
546                        $self->_notice(
547                                __PACKAGE__, ': ', __LINE__,
548                                ": couldn't honour HTTP_ACCEPT_LANGUAGE=$http_accept_language,"
549                                . ' supported languages are: '
550
2
2
4
22
                                . join(',', @{$self->{_supported}})
551                        );
552                }
553
554                # Detected slanguage but rlanguage still Unknown — try I18N::LangTags
555
25
77
                if($self->{_slanguage} && ($self->{_slanguage} ne 'Unknown')) {
556
0
0
                        if($self->{_rlanguage} eq 'Unknown') {
557
0
0
                                $self->{_rlanguage} = I18N::LangTags::Detect::detect();
558                        }
559
0
0
                        if($self->{_rlanguage}) {
560
0
0
                                if(my $resolved = $self->_code2language($self->{_rlanguage})) {
561
0
0
                                        $self->{_rlanguage} = $resolved;
562                                }
563
0
0
                                return;
564                        }
565                }
566
567                # Last-chance: 2-char or xx-xx header where we have no match
568
25
86
                if(
569                        ((!$self->{_rlanguage}) || ($self->{_rlanguage} eq 'Unknown'))
570                        && ((length($http_accept_language) == 2) || ($http_accept_language =~ /^..-..$/))
571                ) {
572
16
19
                        $self->{_rlanguage} = $self->_code2language($http_accept_language) || 'Unknown';
573                }
574
25
91639
                $self->{_slanguage} = 'Unknown';
575        }
576
577
60
70
        return if $self->{_dont_use_ip};
578
579        # Fall back to the official language of the visitor's country
580
59
71
        $self->_find_language_from_ip($http_accept_language);
581}
582
583# ── _accept_language_match ────────────────────────────────────────────────
584# Purpose:      Run I18N::AcceptLanguage strict matching plus two fallback
585#               left-to-right scan passes against $self->{_supported}.
586# Entry:        $http_accept_language — validated, untainted Accept-Language value.
587# Exit:         Returns ($matched_code, $requested_sublanguage) or (undef, undef).
588# Side Effects: Logs debug messages.
589sub _accept_language_match
590{
591
148
137
        my ($self, $http_accept_language) = @_;
592
593        # Suppress I18N::AcceptLanguage's uninitialized-value warnings (RT 74338)
594        local $SIG{__WARN__} = sub {
595
2
40
                warn $_[0] unless $_[0] =~ /^Use of uninitialized value/;
596
148
381
        };
597
148
370
        my $i18n = I18N::AcceptLanguage->new(debug => $self->{_debug}, strict => 1);
598
148
14290
        my $l = $i18n->accepts($http_accept_language, $self->{_supported});
599
148
10163
        local $SIG{__WARN__} = 'DEFAULT';
600
601        # I18N-AcceptLanguage strict mode can return a sublanguage variant when
602        # the request contains a sublanguage we don't support; force a retry.
603
148
679
        if($l && ($http_accept_language =~ /-/) && ($http_accept_language !~ qr/$l/i)) {
604
3
4
                $self->_debug('Forcing fallback');
605
3
57
                undef $l;
606        }
607
608
148
122
        my $requested_sublanguage;
609
148
143
        if(!$l) {
610                # Sort tokens by q-value once; both scan passes share the ordered list
611
28
43
                my $sorted = $self->_sorted_tokens($http_accept_language);
612                # First fallback: scan for xx-yy pairs, try base language xx
613
28
73
                ($l, $requested_sublanguage) =
614                        $self->_scan_sublanguage_pairs($i18n, $sorted);
615
28
40
                if(!$l) {
616                        # Second fallback: scan plain tokens without sublanguages
617
24
41
                        $l = $self->_scan_plain_tokens($i18n, $sorted);
618
24
34
                        undef $requested_sublanguage if $l;
619                }
620        }
621
622
148
592
        return ($l, $requested_sublanguage);
623}
624
625# ── _sorted_tokens ────────────────────────────────────────────────────────
626# Purpose:      Parse an Accept-Language header into tokens sorted by
627#               descending quality value so fallback scans honour q= priority.
628# Entry:        $header — validated Accept-Language string.
629# Exit:         Arrayref of [$language_tag, $quality] pairs, highest q first.
630sub _sorted_tokens
631{
632
36
248
        my ($self, $header) = @_;
633
36
26
        my @tokens;
634
36
50
        for my $token (split /,/, $header) {
635
53
102
                $token =~ s/^\s+|\s+$//g;
636
53
33
                my $q = 1.0;
637
53
84
                if($token =~ s/;\s*q\s*=\s*(\d+(?:\.\d+)?)//) {
638
17
26
                        $q = $1 + 0;
639                }
640
53
82
                $token =~ s/^\s+|\s+$//g;
641
53
83
                push @tokens, [$token, $q] if length $token;
642        }
643
36
21
58
33
        return [sort { $b->[1] <=> $a->[1] } @tokens];
644}
645
646# ── _scan_sublanguage_pairs ───────────────────────────────────────────────
647# Purpose:      Walk q-sorted tokens looking for xx-yy pairs; try accepting
648#               the base language xx from the supported list.
649# Entry:        $i18n — I18N::AcceptLanguage instance;
650#               $sorted — arrayref from _sorted_tokens.
651# Exit:         ($matched_code, $sublanguage_code) or (undef, undef).
652# Side Effects: Debug logging.
653sub _scan_sublanguage_pairs
654{
655
30
34
        my ($self, $i18n, $sorted) = @_;
656
657
30
51
        $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for xx-yy pairs');
658
30
30
939
33
        for my $entry (@{$sorted}) {
659
39
39
196
42
                my ($tag) = @{$entry};
660
39
78
                next unless $tag =~ /^(..)-(..)$/;
661
13
17
                my ($base, $sub) = ($1, $2);
662
13
29
                $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $base is supported");
663
13
1997
                if($i18n->accepts($base, $self->{_supported})) {
664
5
193
                        $self->_debug("Fallback to $base as sublanguage $sub is not supported");
665
5
95
                        return ($base, $sub);
666                }
667        }
668
25
92
        return (undef, undef);
669}
670
671# ── _scan_plain_tokens ────────────────────────────────────────────────────
672# Purpose:      Walk q-sorted tokens that have no sublanguage suffix and try
673#               accepting each against the supported list.
674# Entry:        $i18n — I18N::AcceptLanguage instance;
675#               $sorted — arrayref from _sorted_tokens.
676# Exit:         Matched code string, or undef.
677# Side Effects: Debug logging.
678sub _scan_plain_tokens
679{
680
27
37
        my ($self, $i18n, $sorted) = @_;
681
682
27
33
        $self->_debug(__PACKAGE__, ': ', __LINE__, ': scan q-sorted tokens for plain alternatives');
683
27
27
844
28
        for my $entry (@{$sorted}) {
684
35
35
85
33
                my ($tag) = @{$entry};
685
35
57
                next if $tag =~ /^..-../;    # already tried in the pair scan
686
29
46
                $self->_debug(__PACKAGE__, ': ', __LINE__, ": see if $tag is supported");
687
29
845
                if($i18n->accepts($tag, $self->{_supported})) {
688
3
31
                        $self->_debug("Fallback to $tag as best alternative");
689
3
75
                        return $tag;
690                }
691        }
692
24
794
        return;
693}
694
695# ── _resolve_match ────────────────────────────────────────────────────────
696# Purpose:      Given a matched code $l (possibly xx or xx-yy), populate all
697#               of _slanguage, _rlanguage, _sublanguage and their code fields.
698# Entry:        $l — 2-char or xx-yy language code; $requested_sublanguage —
699#               2-char variety code or undef; $http_accept_language — full header.
700# Exit:         Returns true (1) if the caller should return immediately.
701# Side Effects: Mutates $self->{_slanguage}, _rlanguage, _sublanguage, etc.
702sub _resolve_match
703{
704
126
130
        my ($self, $l, $requested_sublanguage, $http_accept_language) = @_;
705
706
126
169
        $self->_debug("l: $l");
707
708
126
4267
        if($l !~ /^..-../) {
709                # Base-language match (e.g. 'en') — no sublanguage component
710
81
106
                return $self->_resolve_base_match($l, $requested_sublanguage, $http_accept_language);
711        } elsif($l =~ /(.+)-(..)$/) {
712                # Sublanguage match (e.g. 'en-gb') — resolve both language and variant
713
45
62
                return $self->_resolve_sublanguage_match($l, $1, $2, $http_accept_language);
714        }
715
0
0
        return 0;
716}
717
718# ── _resolve_base_match ───────────────────────────────────────────────────
719# Purpose:      Handle the case where a base-language code matched (no hyphen).
720#               Sets _slanguage, _rlanguage; appends sublanguage name to rlanguage
721#               when the client requested one we don't support.
722# Entry:        $l — 2-char code; $requested_sublanguage — optional; $header.
723# Exit:         1 to signal caller should return, 0 otherwise.
724# Side Effects: Mutates slanguage, rlanguage, slanguage_code_alpha2.
725sub _resolve_base_match
726{
727
83
96
        my ($self, $l, $requested_sublanguage, $header) = @_;
728
729
83
99
        $self->{_slanguage} = $self->_code2language($l);
730
83
364579
        return 0 unless $self->{_slanguage};
731
732
79
162
        $self->_debug("_slanguage: $self->{_slanguage}");
733
79
2599
        $self->{_slanguage_code_alpha2} = $l;
734
79
68
        $self->{_rlanguage}             = $self->{_slanguage};
735
736        # Attempt to name the sublanguage the client actually asked for
737
79
44
        my $sl;
738
79
164
        if($header =~ /..-(..)$/) {
739
8
11
                $self->_debug($1);
740
8
242
                $sl = $self->_code2country($1);
741
8
19
                $requested_sublanguage //= $1;
742        } elsif($header =~ /..-([a-z]{2,3})$/i) {
743
1
1
1
2
                eval { $sl = Locale::Object::Country->new(code_alpha3 => $1) };
744
1
242
                $self->_info($@) if $@;
745        }
746
747
79
97
        if($sl) {
748
7
11
                $self->{_rlanguage} .= ' (' . $sl->name() . ')';
749        } elsif($requested_sublanguage) {
750
4
6
                if(my $c = $self->_code2countryname($requested_sublanguage)) {
751
2
12
                        $self->{_rlanguage} .= " ($c)";
752                } else {
753
2
2
                        $self->{_rlanguage} .= " (Unknown: $requested_sublanguage)";
754                }
755        }
756
79
181
        return 1;
757}
758
759# ── _resolve_sublanguage_match ────────────────────────────────────────────
760# Purpose:      Handle the case where the full xx-yy code matched in the
761#               supported list.  Resolves the variety name and caches results.
762# Entry:        $l — full code e.g. 'en-gb'; $alpha2 — 'en'; $variety — 'gb';
763#               $header — full Accept-Language value.
764# Exit:         1 to signal caller should return, 0 otherwise.
765# Side Effects: Mutates _slanguage, _rlanguage, _sublanguage and code fields;
766#               writes to cache.
767sub _resolve_sublanguage_match
768{
769
45
80
        my ($self, $l, $alpha2, $variety, $header) = @_;
770
771
45
66
        my $i18n    = I18N::AcceptLanguage->new(strict => 1);
772
45
547
        my $accepts = $i18n->accepts($l, $self->{_supported});
773
45
1810
        $self->_debug("accepts = $accepts");
774
775
45
1317
        if($accepts) {
776
45
58
                $self->_debug("accepts: $accepts");
777
778
45
1210
                if($accepts =~ /\-/) {
779
45
49
                        delete $self->{_slanguage};
780                } else {
781                        # Cache look-up for the base-language name
782
0
0
                        my $from_cache;
783
0
0
                        if($self->{_cache}) {
784
0
0
                                $from_cache = $self->{_cache}->get($CACHE_NS . "accepts:$accepts");
785                        }
786
0
0
                        my $slanguage;
787
0
0
                        if($from_cache) {
788
0
0
                                $self->_debug("$accepts is in cache as $from_cache");
789
0
0
                                $slanguage = (split(/=/, $from_cache))[0];
790                        } else {
791
0
0
                                $slanguage = $self->_code2language($accepts);
792                        }
793
794
0
0
                        if($slanguage) {
795
0
0
                                $self->{_slanguage} = $slanguage;
796
797                                # Normalise deprecated en-uk variety
798
0
0
                                if($variety eq 'uk') {
799
0
0
                                        $self->_warn({ warning => "Resetting country code to GB for $header" });
800
0
0
                                        $variety = 'gb';
801                                }
802
803
0
0
                                if(defined(my $c = $self->_code2countryname($variety))) {
804
0
0
                                        $self->_debug(__PACKAGE__, ': ', __LINE__, ":  setting sublanguage to $c");
805
0
0
                                        $self->{_sublanguage} = $c;
806                                }
807
0
0
                                $self->{_slanguage_code_alpha2}   = $accepts;
808
0
0
                                $self->{_sublanguage_code_alpha2}  = $variety;
809
810
0
0
                                if($self->{_sublanguage}) {
811
0
0
                                        $self->{_rlanguage} = "$self->{_slanguage} ($self->{_sublanguage})";
812
0
0
                                        $self->_debug(__PACKAGE__, ': ', __LINE__, ": _rlanguage: $self->{_rlanguage}");
813                                }
814
815
0
0
                                unless($from_cache) {
816
0
0
                                        $self->_debug("Set $variety to $slanguage=$accepts");
817                                        $self->{_cache}->set(
818                                                $CACHE_NS . "accepts:$variety",
819                                                "$slanguage=$accepts",
820                                                $CACHE_TTL_LONG
821
0
0
                                        ) if $self->{_cache};
822                                }
823
0
0
                                return 1;
824                        }
825                }
826        }
827
828        # Accepts returned something but we couldn't resolve a language name —
829        # try harder using the variety code directly
830
45
53
        $self->{_rlanguage} = $self->_code2language($alpha2);
831
45
364673
        $self->_debug("_rlanguage: $self->{_rlanguage}");
832
833
45
1345
        return 0 unless $accepts;
834
835
45
59
        $self->_debug("http_accept_language = $header");
836
45
1225
        $l =~ /(..)-(..)/;
837
45
49
        $variety = lc($2);
838
839        # Skip numeric/region codes like en-029
840
45
149
        if(($variety =~ /[a-z]{2,3}/) && !defined($self->{_sublanguage})) {
841
45
76
                $self->_get_closest($alpha2, $alpha2);
842
45
75
                $self->_debug("Find the country code for $variety");
843
844
45
1213
                if($variety eq 'uk') {
845
0
0
                        $self->_warn({ warning => "Resetting country code to GB for $header" });
846
0
0
                        $variety = 'gb';
847                }
848
849
45
36
                my ($from_cache, $language_name);
850
45
51
                if($self->{_cache}) {
851
8
13
                        $from_cache = $self->{_cache}->get($CACHE_NS . "variety:$variety");
852                }
853
854
45
441
                if(defined($from_cache)) {
855
4
10
                        $self->_debug("$variety is in cache as $from_cache");
856                        # Cache stores "countryname=langcode" (e.g. "United Kingdom=en").
857                        # Splitting on = gives the country name as the first field.
858
4
94
                        ($language_name) = split(/=/, $from_cache);
859                } else {
860
41
116
                        my $db = Locale::Object::DB->new();
861
41
41
71353
59
                        my @results = @{$db->lookup(
862                                table         => 'country',
863                                result_column => 'name',
864                                search_column => 'code_alpha2',
865                                value         => $variety
866                        )};
867
41
4234
                        if(defined($results[0])) {
868
40
40
31
54
                                eval { $language_name = $self->_code2countryname($variety) };
869                        } else {
870
1
2
                                $self->_debug("Can't find the country code for $variety in Locale::Object::DB");
871                        }
872                }
873
874
45
280
                if($@ || !defined($language_name)) {
875
1
2
                        $self->_warn({ warning => $@ }) if $@;
876
1
2
                        $self->_debug(__PACKAGE__, ': ', __LINE__, ': setting sublanguage to Unknown');
877
1
30
                        $self->{_sublanguage} = 'Unknown';
878
1
3
                        $self->_warn({ warning => "Can't determine values for $header" });
879                } else {
880
44
65
                        $self->{_sublanguage} = $language_name;
881
44
61
                        $self->_debug('variety name ', $self->{_sublanguage});
882
44
1315
                        if($self->{_cache} && !defined($from_cache)) {
883                                # Store "countryname=langcode" so future cache hits return the country
884                                # name in the first field.  Previously this stored the language name
885                                # ("English=en" for en-gb) which was wrong — the cache-hit branch
886                                # split on = and used the first field as the sublanguage (country) name.
887
4
10
                                $self->_debug("Set variety:$variety to $language_name=$self->{_slanguage_code_alpha2}");
888                                $self->{_cache}->set(
889
4
90
                                        $CACHE_NS . "variety:$variety",
890                                        "$language_name=$self->{_slanguage_code_alpha2}",
891                                        $CACHE_TTL_LONG
892                                );
893                        }
894                }
895        }
896
897
45
534
        if(defined($self->{_sublanguage})) {
898
45
53
                $self->{_rlanguage} = "$self->{_slanguage} ($self->{_sublanguage})";
899
45
47
                $self->{_sublanguage_code_alpha2} = $variety;
900
45
121
                return 1;
901        }
902
0
0
        return 0;
903}
904
905# ── _find_language_from_ip ────────────────────────────────────────────────
906# Purpose:      Fall back to the visitor's IP country when the Accept-Language
907#               header produced no usable match.  Looks up the official language
908#               of the country and checks it against the supported list.
909# Entry:        $http_accept_language — may be undef if no header was present.
910# Exit:         Mutates _slanguage, _rlanguage via _get_closest if a match found.
911# Side Effects: Calls country(); may write to cache.
912sub _find_language_from_ip
913{
914
59
55
        my ($self, $http_accept_language) = @_;
915
916
59
66
        my $country = $self->country();
917
918        # If country() returned nothing, try to derive from the LANG env var
919
59
89
        if(!defined($country) && (my $c = $self->_what_language())) {
920
25
57
                if($c =~ /^(..)_(..)/) {
921
2
3
                        $country = $2;
922                } elsif($c =~ /^(..)$/) {
923
14
14
                        $country = $1;
924                }
925        }
926
59
86
        return unless defined $country;
927
928
20
29
        $self->_debug("country: $country");
929
930
20
618
        my ($language_name, $language_code2, $from_cache);
931
20
29
        if($self->{_cache}) {
932
2
4
                $from_cache = $self->{_cache}->get($CACHE_NS . 'language_name:' . $country);
933        }
934
935
20
114
        if($from_cache) {
936
1
2
                $self->_debug("$country is in cache as $from_cache");
937
1
33
                ($language_name, $language_code2) = split(/=/, $from_cache);
938        } else {
939
19
30
                my $l = $self->_code2country(uc($country));
940
19
30
                if($l) {
941
12
17
                        $l = ($l->languages_official)[0];
942
12
5771
                        if(defined $l) {
943
12
18
                                $language_name  = $l->name;
944
12
40
                                $language_code2 = $l->code_alpha2;
945
12
52
                                $self->_debug("Official language: $language_name") if $language_name;
946                        }
947                }
948        }
949
950
20
454
        my $ip = $ENV{'REMOTE_ADDR'};
951
20
26
        return unless $language_name;
952
953
13
35
        if((!defined($self->{_rlanguage})) || ($self->{_rlanguage} eq 'Unknown')) {
954
6
6
                $self->{_rlanguage} = $language_name;
955        }
956
957
13
31
        unless((exists $self->{_slanguage}) && ($self->{_slanguage} ne 'Unknown')) {
958
13
10
                my $code;
959
960
13
42
                if($language_name && $language_code2 && !defined($http_accept_language)) {
961                        # Fast-path for search engines that hit with no Accept-Language
962
4
5
                        $self->_debug("Fast assign to $language_code2");
963
4
117
                        $code = $language_code2;
964                } else {
965
9
20
                        $self->_debug("Call language2code on $self->{_rlanguage}");
966
9
293
                        $code = Locale::Language::language2code($self->{_rlanguage});
967
968
9
90432
                        unless($code) {
969
0
0
                                if($http_accept_language && ($http_accept_language ne $self->{_rlanguage})) {
970
0
0
                                        $self->_debug("Call language2code on $http_accept_language");
971
0
0
                                        $code = Locale::Language::language2code($http_accept_language);
972                                }
973
0
0
                                unless($code) {
974                                        # Norwegian (Nynorsk) — strip the parenthetical qualifier
975
0
0
                                        if($self->{_rlanguage} =~ /(.+)\s\(.+/) {
976
0
0
                                                if((!defined($http_accept_language)) || ($1 ne $self->{_rlanguage})) {
977
0
0
                                                        $self->_debug("Call language2code on $1");
978
0
0
                                                        $code = Locale::Language::language2code($1);
979                                                }
980                                        }
981
0
0
                                        unless($code) {
982
0
0
                                                $self->_warn({
983                                                        warning => "Can't determine code from IP $ip for requested language $self->{_rlanguage}"
984                                                });
985                                        }
986                                }
987                        }
988                }
989
990
13
15
                if($code) {
991
13
24
                        $self->_get_closest($code, $language_code2);
992
13
15
                        unless($self->{_slanguage}) {
993
0
0
                                $self->_warn({
994                                        warning => "Couldn't determine closest language for $language_name in $self->{_supported}"
995                                });
996                        } else {
997
13
20
                                $self->_debug("language set to $self->{_slanguage}, code set to $code");
998                        }
999                }
1000        }
1001
1002
13
470
        if(!defined($self->{_slanguage_code_alpha2})) {
1003
8
9
                $self->_debug("Can't determine slanguage_code_alpha2");
1004        } elsif(!defined($from_cache) && $self->{_cache} && defined($self->{_slanguage_code_alpha2})) {
1005
1
3
                $self->_debug("Set $country to $language_name=$self->{_slanguage_code_alpha2}");
1006                $self->{_cache}->set(
1007
1
30
                        $CACHE_NS . 'language_name:' . $country,
1008                        "$language_name=$self->{_slanguage_code_alpha2}",
1009                        $CACHE_TTL_LONG
1010                );
1011        }
1012}
1013
1014# ── _get_closest ─────────────────────────────────────────────────────────
1015# Purpose:      If $language_string matches the base language of any supported
1016#               entry, set _slanguage and _slanguage_code_alpha2.
1017# Entry:        $language_string — base code e.g. 'en'; $alpha2 — same or variant.
1018# Exit:         Mutates _slanguage and _slanguage_code_alpha2 on match.
1019sub _get_closest
1020{
1021
63
107
        my ($self, $language_string, $alpha2) = @_;
1022
1023        # Map each supported entry to its base language code
1024        my %base_languages =
1025
63
152
63
60
329
81
                map { /^(.+)-/ ? ($1 => $_) : ($_ => $_) } @{$self->{_supported}};
1026
1027
63
85
        if(exists $base_languages{$language_string}) {
1028
53
49
                $self->{_slanguage}             = $self->{_rlanguage};
1029
53
71
                $self->{_slanguage_code_alpha2} = $alpha2;
1030        }
1031}
1032
1033# ── _what_language ────────────────────────────────────────────────────────
1034# Purpose:      Return the raw (validated, untainted) Accept-Language string,
1035#               consulting in priority order: cached value, CGI lang= param,
1036#               HTTP_ACCEPT_LANGUAGE env var, LANG env var (local/debug mode).
1037# Entry:        May be called as a class method (no $self->{...} access) or
1038#               as an object method.
1039# Exit:         A validated language string, or undef if nothing available.
1040# Side Effects: Caches result in $self->{_what_language} on object calls.
1041sub _what_language {
1042
321
2993
        my $self = $_[0];
1043
1044
321
292
        if(ref($self)) {
1045
248
257
                $self->_trace('Entered _what_language');
1046
248
7796
                if(defined($self->{_what_language})) {
1047
27
33
                        $self->_trace('_what_language: returning cached value: ', $self->{_what_language});
1048
27
856
                        return $self->{_what_language};
1049                }
1050
221
247
                if(my $info = $self->{_info}) {
1051
2
4
                        if(my $rc = $info->lang()) {
1052
2
6
                                $self->_trace("_what_language set language to $rc from the lang argument");
1053
2
62
                                return $self->{_what_language} = $rc;
1054                        }
1055                }
1056        }
1057
1058
292
364
        if(my $raw_lang = $ENV{'HTTP_ACCEPT_LANGUAGE'}) {
1059                # Validate and untaint — RFC 7231 §5.3.5 character set plus * wildcard
1060
239
497
                if($raw_lang =~ /^([A-Za-z0-9\-,;=.*\s]{1,$ACCEPT_LANG_MAX})$/a) {
1061
195
1129
                        my $rc = $1;    # untainted
1062
195
198
                        if(ref($self)) {
1063
147
290
                                return $self->{_what_language} = $rc;
1064                        }
1065
48
92
                        return $rc;
1066                } elsif(ref($self)) {
1067
44
245
                        $self->_warn({ warning => 'HTTP_ACCEPT_LANGUAGE contains invalid characters; ignoring' });
1068                }
1069        }
1070
1071
97
2610
        if(defined($ENV{'LANG'})) {
1072                # Running locally (debug mode) — derive from system locale.
1073                # Apply the same untainting discipline as HTTP_ACCEPT_LANGUAGE: only
1074                # alphanumeric, hyphen, underscore, and dot are legitimate in a POSIX
1075                # locale name (e.g. "en_US.UTF-8", "de_DE", "ja").  Anything else is
1076                # either malformed or an injection attempt; discard it silently.
1077
15
33
                if($ENV{'LANG'} =~ /^([A-Za-z0-9_.\-]{1,$ACCEPT_LANG_MAX})$/a) {
1078
5
110
                        my $rc = $1;    # untainted
1079
5
8
                        if(ref($self)) {
1080
5
12
                                return $self->{_what_language} = $rc;
1081                        }
1082
0
0
                        return $rc;
1083                } elsif(ref($self)) {
1084
10
94
                        $self->_warn({ warning => 'LANG contains invalid characters; ignoring' });
1085                }
1086        }
1087
92
1354
        return;
1088}
1089
1090 - 1141
=head2 country

Returns the two-character country code of the remote end in lowercase.

If L<IP::Country>, L<Geo::IPfree> or L<Geo::IP> is installed,
CGI::Lingua will make use of that, otherwise, it will do a Whois lookup.
If you do not have any of those installed I recommend you use the
caching capability of CGI::Lingua.

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str (2 lowercase chars) | undef
      'Unknown' is only returned in the Baidu-EU special case via _handle_eu_country.

=head3 EXAMPLE

    # With mod_geoip (fastest - no IP lookup at all):
    local $ENV{GEOIP_COUNTRY_CODE} = 'DE';
    print $l->country();   # "de"

    # With REMOTE_ADDR and IP::Country installed:
    local $ENV{REMOTE_ADDR} = '8.8.8.8';
    print $l->country();   # "us" (depends on geo database)

=head3 MESSAGES

    "GEOIP_COUNTRY_CODE contains an invalid country code; ignoring"
    "HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring"
    "X.X.X.X isn't a valid IP address"
    "Can't determine country from LAN connection X"
    "Can't determine country from loopback connection X"
    "cache contains a numeric country: N"
    "IP matches to a numeric country"

=head3 PSEUDOCODE

    1. Return cached _country if set
    2. Check GEOIP_COUNTRY_CODE env var (mod_geoip); validate /^[A-Z]{2}$/
    3. Check HTTP_CF_IPCOUNTRY (Cloudflare); skip 'XX'; validate /^[A-Z]{2}$/
    4. Untaint and validate REMOTE_ADDR; return undef if absent or invalid
    5. Skip private and loopback IPs (return undef)
    6. Check CHI cache; return cached value if present
    7. Try IP::Country::Fast (local DB, fastest)
    8. Try Geo::IP (local DB)
    9. Try Geo::IPfree (local DB, skip $BROKEN_GEOIPFREE)
    10. Try geoplugin.net JSON API (LWP::Simple::WithCache or LWP::Simple)
    11. Last resort: Net::Whois::IP then Net::Whois::IANA
    12. Sanitise: discard numeric, normalise HK->CN, handle EU special case
    13. Store in CHI cache; return result

=cut
1142
1143sub country {
1144
192
2677
        my $self = shift;
1145
1146
192
232
        $self->_trace(__PACKAGE__, ': Entered country()');
1147
1148        # Return cached result immediately if a previous call already resolved it.
1149        # Note: undef results (private/loopback IPs) are NOT cached here because
1150        # country() reads REMOTE_ADDR at call time, not construction time; caching
1151        # undef would give wrong answers if REMOTE_ADDR changes between calls on
1152        # the same object (the documented lazy-read design).  See LIMITATIONS.
1153
192
6976
        if($self->{_country}) {
1154
7
9
                $self->_trace('quick return: ', $self->{_country});
1155
7
230
                return $self->{_country};
1156        }
1157
1158        # mod_geoip: validate against ISO 3166-1 alpha-2 before trusting
1159
185
182
        if(defined($ENV{'GEOIP_COUNTRY_CODE'})) {
1160
28
56
                if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})$/a) {
1161
9
15
                        $self->{_country} = lc($1);
1162
9
14
                        return $self->{_country};
1163                } else {
1164
19
29
                        $self->_warn({ warning => 'GEOIP_COUNTRY_CODE contains an invalid country code; ignoring' });
1165                }
1166        }
1167
1168        # Cloudflare: 'XX' means Cloudflare couldn't determine country — skip it
1169
176
489
        if(($ENV{'HTTP_CF_IPCOUNTRY'}) && ($ENV{'HTTP_CF_IPCOUNTRY'} ne 'XX')) {
1170
9
22
                if($ENV{'HTTP_CF_IPCOUNTRY'} =~ /^([A-Z]{2})$/a) {
1171
5
10
                        $self->{_country} = lc($1);
1172
5
13
                        return $self->{_country};
1173                } else {
1174
4
9
                        $self->_warn({ warning => 'HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring' });
1175                }
1176        }
1177
1178
171
141
        my $raw_ip = $ENV{'REMOTE_ADDR'};
1179
171
191
        return unless defined $raw_ip;
1180
1181        # Validate and untaint the IP address before passing to any geo module
1182
135
75
        my $ip;
1183
135
340
        if($raw_ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/a) {
1184
112
111
                $ip = $1;    # untainted IPv4
1185        } elsif($raw_ip =~ /^([0-9a-fA-F:]{2,39}|[0-9a-fA-F:]{2,30}:(?:\d{1,3}\.){3}\d{1,3})$/a) {
1186
7
10
                $ip = $1;    # untainted IPv6, including mixed notation (e.g. ::ffff:192.0.2.1)
1187        } else {
1188
16
34
                $self->_warn({ warning => "$raw_ip isn't a valid IP address" });
1189
16
585
                return;
1190        }
1191
1192
119
1555
        require Data::Validate::IP;
1193
119
102454
        Data::Validate::IP->import();
1194
1195
119
151
        if(!is_ipv4($ip)) {
1196
8
58
                $self->_debug("$ip isn't IPv4. Is it IPv6?");
1197
8
279
                if($ip eq '::1') {
1198
4
7
                        $ip = '127.0.0.1';    # normalise loopback
1199                } elsif($ip =~ /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i) {
1200
3
4
                        $ip = $1;             # normalise IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4
1201                        # \d{1,3} matches 0-999; validate range before geo lookups because
1202                        # some inet_aton implementations wrap out-of-range octets modulo 256,
1203                        # turning 999.999.999.999 into a real routable address.
1204
3
4
                        unless(is_ipv4($ip)) {
1205
1
8
                                $self->_warn({ warning => "$ip isn't a valid IP address" });
1206
1
140
                                return;
1207                        }
1208                } elsif(!is_ipv6($ip)) {
1209
1
9
                        $self->_warn({ warning => "$ip isn't a valid IP address" });
1210
1
2
                        return;
1211                }
1212        }
1213
117
2194
        if(is_private_ip($ip)) {
1214
3
101
                $self->_debug("Can't determine country from LAN connection $ip");
1215
3
100
                return;
1216        }
1217
114
6762
        if(is_loopback_ip($ip)) {
1218
70
2154
                $self->_debug("Can't determine country from loopback connection $ip");
1219
70
2507
                return;
1220        }
1221
1222        # Cache look-up — skip for LAN/loopback (already returned above)
1223
44
2053
        if($self->{_cache}) {
1224
14
31
                $self->{_country} = $self->{_cache}->get($CACHE_NS . "country:$ip");
1225
14
676
                if(defined($self->{_country})) {
1226
6
11
                        if($self->{_country} !~ /\D/) {
1227
5
18
                                $self->_warn({ warning => 'cache contains a numeric country: ' . $self->{_country} });
1228
5
129
                                $self->{_cache}->remove($CACHE_NS . "country:$ip");
1229
5
189
                                delete $self->{_country};
1230                        } else {
1231
1
3
                                $self->_debug("Get $ip from cache = $self->{_country}");
1232
1
53
                                return $self->{_country};
1233                        }
1234                }
1235
13
24
                $self->_debug("$ip isn't in the cache");
1236        }
1237
1238        # Try IP::Country first (fastest, local database)
1239
43
519
        if($self->{_have_ipcountry} == $GEO_UNKNOWN) {
1240
0
0
0
0
                if(eval { require IP::Country::Fast }) {
1241                        # Require the concrete class directly; IP::Country->import() is not needed
1242                        # because IP::Country::Fast->new() is a fully-qualified class method call.
1243
0
0
                        $self->{_have_ipcountry} = $GEO_PRESENT;
1244
0
0
                        $self->{_ipcountry}      = IP::Country::Fast->new();
1245                } else {
1246
0
0
                        $self->{_have_ipcountry} = $GEO_ABSENT;
1247                }
1248        }
1249
43
146
        $self->_debug("have_ipcountry $self->{_have_ipcountry}");
1250
1251
43
1464
        if($self->{_have_ipcountry}) {
1252
26
41
                $self->{_country} = $self->{_ipcountry}->inet_atocc($ip);
1253
26
63
                if($self->{_country}) {
1254
24
29
                        $self->{_country} = lc($self->{_country});
1255                } elsif(is_ipv4($ip)) {
1256
2
16
                        $self->_debug("$ip is not known by IP::Country");
1257                }
1258        }
1259
1260        # Try Geo::IP if IP::Country gave nothing
1261
43
106
        unless(defined($self->{_country})) {
1262
18
21
                if($self->{_have_geoip} == $GEO_UNKNOWN) {
1263
1
4
                        $self->_load_geoip();
1264                }
1265
18
50
                if($self->{_have_geoip} == $GEO_PRESENT) {
1266
1
4
                        $self->{_country} = $self->{_geoip}->country_code_by_addr($ip);
1267                }
1268
1269                # Geo::IPfree has a known-broken entry for $BROKEN_GEOIPFREE
1270
18
57
                if(!defined($self->{_country}) && ($ip ne $BROKEN_GEOIPFREE)) {
1271
17
62
                        if($self->{_have_geoipfree} == $GEO_UNKNOWN) {
1272
0
0
0
0
                                eval { require Geo::IPfree };
1273
0
0
                                unless($@) {
1274                                        # No ->import(): Geo::IPfree uses only OO (->LookUp); the old
1275                                        # Geo::IPfree::IP->import() call was a wrong package and could
1276                                        # clobber Test::Mockingbird mocks on some versions.
1277
0
0
                                        $self->{_have_geoipfree} = $GEO_PRESENT;
1278
0
0
                                        $self->{_geoipfree}      = Geo::IPfree->new();
1279                                } else {
1280
0
0
                                        $self->{_have_geoipfree} = $GEO_ABSENT;
1281                                }
1282                        }
1283
17
41
                        if($self->{_have_geoipfree} == $GEO_PRESENT) {
1284
1
3
                                if(my $country = ($self->{_geoipfree}->LookUp($ip))[0]) {
1285
1
3
                                        $self->{_country} = lc($country);
1286                                }
1287                        }
1288                }
1289        }
1290
1291        # 'eu' is not a real country — discard
1292
43
102
        if($self->{_country} && ($self->{_country} eq 'eu')) {
1293
2
2
                delete $self->{_country};
1294        }
1295
1296        # Remote JSON lookup via geoplugin
1297
43
53
        if((!$self->{_country}) &&
1298
19
0
824
0
           (eval { require LWP::Simple::WithCache; require JSON::Parse })) {
1299
0
0
                $self->_debug("Look up $ip on geoplugin");
1300
1301
0
0
                if(my $data = LWP::Simple::WithCache::get("https://www.geoplugin.net/json.gp?ip=$ip")) {
1302
0
0
0
0
                        eval { $self->{_country} = JSON::Parse::parse_json($data)->{'geoplugin_countryCode'} };
1303
0
0
                        $self->_warn({ warning => "geoplugin returned unparseable JSON: $@" }) if $@;
1304                }
1305        }
1306
1307        # Last resort: Whois
1308
43
127
        unless($self->{_country}) {
1309
19
30
                $self->_resolve_country_via_whois($ip);
1310        }
1311
1312        # Sanitise and normalise whatever we found
1313
43
52
        if($self->{_country}) {
1314
29
49
                if($self->{_country} !~ /\D/) {
1315
2
4
                        $self->_warn({ warning => 'IP matches to a numeric country' });
1316
2
118
                        delete $self->{_country};
1317                } else {
1318
27
30
                        $self->{_country} = lc($self->{_country});
1319
1320                        # Legacy mappings
1321
27
39
                        if($self->{_country} eq 'hk') {
1322
3
4
                                $self->{_country} = 'cn';    # HK is no longer a separate country in Whois
1323                        } elsif($self->{_country} eq 'eu') {
1324
2
3
                                $self->_handle_eu_country($ip);
1325                        }
1326
1327
27
144
                        if($self->{_country} && ($self->{_country} !~ /\D/)) {
1328
0
0
                                $self->_warn({ warning => "cache contains a numeric country: $self->{_country}" });
1329
0
0
                                delete $self->{_country};
1330                        } elsif($self->{_country} &&
1331                                $self->{_country} ne 'Unknown' &&
1332                                ($self->{_country} !~ /^[a-z]{2}$/)) {
1333                                # Reject anything that is not exactly 2 lowercase ASCII letters,
1334                                # unless it is the 'Unknown' sentinel written by _handle_eu_country
1335                                # for EU addresses that do not map to a specific country.
1336                                # Guards against Whois CRLF injection leftovers ("gbx-header: evil")
1337                                # and XSS payloads in JSON API responses ("gb<script>...</script>").
1338
0
0
                                $self->_warn({ warning => "Discarding malformed country code '$self->{_country}'" });
1339
0
0
                                delete $self->{_country};
1340                        } elsif($self->{_country} && $self->{_cache}) {
1341
7
12
                                $self->_debug("Set $ip to $self->{_country}");
1342                                $self->{_cache}->set(
1343                                        $CACHE_NS . "country:$ip",
1344                                        $self->{_country},
1345
7
214
                                        $CACHE_TTL_SHORT
1346                                );
1347                        }
1348                }
1349        }
1350
1351
43
909
        return $self->{_country};
1352}
1353
1354# ── _resolve_country_via_whois ─────────────────────────────────────────────
1355# Purpose:      Attempt Net::Whois::IP then Net::Whois::IANA as a last resort.
1356# Entry:        $ip — validated, untainted IP string.
1357# Exit:         Sets $self->{_country} if a result was found.
1358# Side Effects: Network I/O; logs debug messages.
1359sub _resolve_country_via_whois
1360{
1361
15
114
        my ($self, $ip) = @_;
1362
1363
15
46
        $self->_debug("Look up $ip on Whois");
1364
1365
15
872
        require Net::Whois::IP;
1366        # No ->import(): whoisip_query is called fully-qualified, so import is unneeded
1367        # and on some versions reinstalls the real function, clobbering Test::Mockingbird mocks.
1368
1369
15
6641
        my $whois;
1370
15
9
        eval {
1371                # Catch connection timeouts by converting Carp::carp into a die
1372
15
1
38
9
                local $SIG{__WARN__} = sub { die $_[0] };
1373
15
17
                $whois = Net::Whois::IP::whoisip_query($ip);
1374        };
1375
1376
15
1135797
        unless($@ || !defined($whois) || (ref($whois) ne 'HASH')) {
1377
12
15
                if(defined($whois->{Country})) {
1378
11
22
                        $self->{_country} = $whois->{Country};
1379                } elsif(defined($whois->{country})) {
1380
1
2
                        $self->{_country} = $whois->{country};
1381                }
1382
12
14
                if($self->{_country}) {
1383
12
26
                        if($self->{_country} eq 'EU') {
1384
2
2
                                delete $self->{_country};
1385                        } elsif(($self->{_country} eq 'US') && defined($whois->{'StateProv'}) && ($whois->{'StateProv'} eq 'PR')) {
1386                                # RT#131347: Puerto Rico is not the US
1387
1
2
                                $self->{_country} = 'pr';
1388                        }
1389                }
1390        }
1391
1392
15
20
        if($self->{_country}) {
1393
10
19
                $self->_debug("Found $ip on Net::Whois::IP as ", $self->{_country});
1394
10
405
                $self->{_country} = _clean_country_code($self->{_country});
1395                # _clean_country_code returns undef for malformed values (e.g. CRLF
1396                # injection leftovers); if so, fall through to the IANA look-up.
1397
10
29
                return if defined $self->{_country};
1398
1
1
                delete $self->{_country};
1399        }
1400
1401
6
9
        $self->_debug("Look up $ip on IANA");
1402
1403
6
420
        require Net::Whois::IANA;
1404        # No ->import(): Net::Whois::IANA->new() is a class method; import not needed.
1405
1406
6
5095
        my $iana = Net::Whois::IANA->new();
1407
6
6
11
9
        eval { $iana->whois_query(-ip => $ip) };
1408
6
132515
        unless($@) {
1409
6
9
                $self->{_country} = $iana->country();
1410
6
21
                $self->_debug("IANA reports $ip as ", $self->{_country});
1411        }
1412
1413
6
207
        if($self->{_country}) {
1414
5
8
                $self->{_country} = _clean_country_code($self->{_country});
1415
5
18
                delete $self->{_country} unless defined $self->{_country};
1416        }
1417}
1418
1419# ── _clean_country_code ───────────────────────────────────────────────────
1420# Purpose:      Strip carriage returns and trailing "#…" comments that some
1421#               Whois servers append to their country field
1422#               (e.g. "US\r", "GB # United Kingdom").
1423# Entry:        $raw — raw country string from a Whois response.
1424# Exit:         Cleaned 2-char country code string.
1425sub _clean_country_code
1426{
1427
15
13
        my ($raw) = @_;
1428
15
22
        $raw =~ s/[\r\n]//g;
1429        # Accept exactly 2 alpha chars, optionally followed by whitespace and a
1430        # comment (e.g. "GB # United Kingdom").  Anything else (CRLF injection
1431        # leftovers, embedded headers) returns undef so the caller can discard it
1432        # rather than propagating a malformed string through the geo pipeline.
1433
15
65
        if($raw =~ /^([A-Za-z]{2})\s*(?:#.*)?$/) {
1434
14
23
                return $1;
1435        }
1436
1
1
        return;
1437}
1438
1439# ── _handle_eu_country ────────────────────────────────────────────────────
1440# Purpose:      Resolve the ambiguous 'eu' country code.  RT-86809 shows that
1441#               Baidu reports itself as EU when it is actually in CN.  All
1442#               other 'eu' addresses are logged as Unknown.
1443# Entry:        $ip — validated, untainted IP string.
1444# Exit:         Sets $self->{_country} to 'cn' or 'Unknown'.
1445# Side Effects: Loads Net::Subnet; writes info log entry.
1446sub _handle_eu_country
1447{
1448
4
23
        my ($self, $ip) = @_;
1449
1450
4
391
        require Net::Subnet;
1451
4
1688
        Net::Subnet->import();
1452
1453
4
7
        if(subnet_matcher($BAIDU_SUBNET)->($ip)) {
1454
2
82
                $self->{_country} = 'cn';
1455        } else {
1456
2
39
                $self->_info("$ip has country of eu");
1457
2
81
                $self->{_country} = 'Unknown';
1458        }
1459}
1460
1461# ── _load_geoip ───────────────────────────────────────────────────────────
1462# Purpose:      Probe for the Geo::IP database file and the Geo::IP module;
1463#               set _have_geoip and initialise _geoip on success.
1464# Entry:        _have_geoip must be GEO_UNKNOWN.
1465# Exit:         _have_geoip set to GEO_PRESENT or GEO_ABSENT.
1466# Side Effects: Requires Geo::IP; opens GeoIP.dat.
1467sub _load_geoip
1468{
1469
3
21
        my $self = shift;
1470
1471        # Check for the database file before even trying to load the module
1472        # (avoids noisy errors on Windows — CPANTESTERS report 54117bd0)
1473
3
42
        my $db_present = (
1474                (($^O eq 'MSWin32') && (-r 'c:/GeoIP/GeoIP.dat'))
1475                || (-r '/usr/local/share/GeoIP/GeoIP.dat')
1476                || (-r '/usr/share/GeoIP/GeoIP.dat')
1477        );
1478
1479
3
3
        unless($db_present) {
1480
3
6
                $self->{_have_geoip} = $GEO_ABSENT;
1481
3
6
                return;
1482        }
1483
1484
0
0
0
0
        eval { require Geo::IP };
1485
0
0
        if($@) {
1486
0
0
                $self->{_have_geoip} = $GEO_ABSENT;
1487
0
0
                return;
1488        }
1489
1490        # No ->import(): Geo::IP->open() and Geo::IP->new() are class methods; import unneeded.
1491
0
0
        $self->{_have_geoip} = $GEO_PRESENT;
1492
1493        # GEOIP_STANDARD = 0 (can't use the constant name directly)
1494
0
0
        if(-r '/usr/share/GeoIP/GeoIP.dat') {
1495
0
0
                $self->{_geoip} = Geo::IP->open('/usr/share/GeoIP/GeoIP.dat', 0);
1496        } else {
1497
0
0
                $self->{_geoip} = Geo::IP->new(0);
1498        }
1499}
1500
1501 - 1534
=head2 locale

HTTP doesn't have a way of transmitting a browser's localisation information
which would be useful for default currency, date formatting, etc.

This method attempts to detect the information, but it is a best guess
and is not 100% reliable.  But it's better than nothing ;-)

Returns a L<Locale::Object::Country> object.

=head3 EXAMPLE

    local $ENV{REMOTE_ADDR} = '8.8.8.8';
    my $locale = $l->locale();
    if (defined $locale) {
        print $locale->name();          # e.g. "United States"
        print $locale->currency_code(); # e.g. "USD"
    }

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Locale::Object::Country | undef

=head3 PSEUDOCODE

    1. Return cached _locale immediately if already computed
    2. Parse HTTP_USER_AGENT parenthetical for xx-YY language tag
    3. Try HTTP::BrowserDetect on the full User-Agent string
    4. Fall back to country() IP lookup
    5. Fall back to GEOIP_COUNTRY_CODE env var (ISO 3166-1 validated)
    6. Return undef if all strategies fail

=cut
1535
1536sub locale {
1537
22
619
        my $self = shift;
1538
1539
22
40
        return $self->{_locale} if $self->{_locale};
1540
1541        # Validate and untaint HTTP_USER_AGENT before passing to any parser.
1542        # The User-Agent header is attacker-controlled; apply the same discipline
1543        # as HTTP_ACCEPT_LANGUAGE.  Printable ASCII (0x20-0x7e), bounded length.
1544
20
16
        my $agent;
1545
20
30
        if(defined(my $raw_agent = $ENV{'HTTP_USER_AGENT'})) {
1546
10
21
                if($raw_agent =~ /^([\x20-\x7e]{1,$UA_MAX})$/a) {
1547
8
68
                        $agent = $1;    # untainted
1548                } else {
1549
2
20
                        $self->_warn({ warning => 'HTTP_USER_AGENT contains invalid characters or exceeds length limit; ignoring' });
1550                }
1551        }
1552
1553        # First try: parse the language tag from the User-Agent parenthetical
1554
20
275
        if(defined($agent) && ($agent =~ /\((.+)\)/)) {
1555
7
13
                foreach(split(/;/, $1)) {
1556
16
13
                        my $candidate = $_;
1557
16
36
                        $candidate =~ s/^\s+|\s+$//g;    # trim both ends
1558
1559
16
21
                        if($candidate =~ /^[a-zA-Z]{2}-([a-zA-Z]{2})$/) {
1560
2
5
                                local $SIG{__WARN__} = undef;
1561
2
3
                                if(my $c = $self->_code2country($1)) {
1562
2
3
                                        $self->{_locale} = $c;
1563
2
7
                                        return $c;
1564                                }
1565                        }
1566                }
1567
1568                # Second try: HTTP::BrowserDetect (works for more User-Agents)
1569
5
5
5
817
                if(eval { require HTTP::BrowserDetect }) {
1570
5
15994
                        HTTP::BrowserDetect->import();
1571
5
10
                        my $browser = HTTP::BrowserDetect->new($agent);
1572                        # Validate country() result before use — the return value comes from
1573                        # the third-party module and is not yet untainted or range-checked.
1574
5
442
                        if($browser) {
1575
5
11
                                my $bc = $browser->country() // '';
1576
5
163
                                if($bc =~ /^([A-Za-z]{2})$/a) {
1577
1
2
                                        if(my $c = $self->_code2country($1)) {
1578
1
2
                                                $self->{_locale} = $c;
1579
1
1716
                                                return $c;
1580                                        }
1581                                }
1582                        }
1583                }
1584        }
1585
1586        # Third try: IP address
1587
17
23
        my $country = $self->country();
1588
17
19
        if($country) {
1589
8
12
                $country =~ s/[\r\n]//g;
1590
8
6
                my $c;
1591
8
6
                eval {
1592
8
0
24
0
                        local $SIG{__WARN__} = sub { die $_[0] };
1593
8
11
                        $c = $self->_code2country($country);
1594                };
1595
8
14
                unless($@) {
1596
8
10
                        if($c) {
1597
8
11
                                $self->{_locale} = $c;
1598
8
10
                                return $c;
1599                        }
1600                }
1601        }
1602
1603        # Fourth try: mod_geoip env var — apply the same ISO 3166-1 validation
1604        # used in country() to guard against spoofed or malformed values
1605
9
14
        if(defined($ENV{'GEOIP_COUNTRY_CODE'})) {
1606
2
4
                if($ENV{'GEOIP_COUNTRY_CODE'} =~ /^([A-Z]{2})$/a) {
1607
0
0
                        if(my $c = $self->_code2country(lc($1))) {
1608
0
0
                                $self->{_locale} = $c;
1609
0
0
                                return $c;
1610                        }
1611                }
1612        }
1613
9
10
        return;
1614}
1615
1616 - 1654
=head2 time_zone

Returns the timezone of the web client.

If L<Geo::IP> is installed,
CGI::Lingua will make use of that, otherwise it will use L<ip-api.com>

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: Str (IANA timezone name) | undef

=head3 EXAMPLE

    local $ENV{REMOTE_ADDR} = '8.8.8.8';
    my $tz = $l->time_zone();
    print $tz // 'unknown';   # e.g. "America/New_York"

=head3 MESSAGES

    "Couldn't determine the timezone"
    "LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com"
      Returns undef rather than croaking; install either LWP variant to enable ip-api lookups.

=head3 PSEUDOCODE

    1. Return cached _timezone immediately if already computed
    2. If REMOTE_ADDR is set:
       a. Untaint and validate the IP
       b. Try Geo::IP->time_zone() (local DB)
       c. Try LWP::Simple::WithCache + JSON::Parse against ip-api.com
       d. Fall back to LWP::Simple + JSON::Parse against ip-api.com
       e. Warn and return undef if neither LWP variant is installed
    3. If REMOTE_ADDR is absent (local/CLI mode):
       a. Read /etc/timezone if readable
       b. Fall back to DateTime::TimeZone::Local->TimeZone()->name()
    4. Warn "Couldn't determine the timezone" and return undef if all fail

=cut
1655
1656sub time_zone {
1657
12
668
        my $self = shift;
1658
1659
12
18
        $self->_trace('Entered time_zone');
1660
1661
11
460
        if($self->{_timezone}) {
1662
3
5
                $self->_trace('quick return: ', $self->{_timezone});
1663
3
91
                return $self->{_timezone};
1664        }
1665
1666
8
12
        my $raw_ip = $ENV{'REMOTE_ADDR'};
1667
1668
8
9
        if(defined $raw_ip) {
1669                # Untaint before any external use — kept in sync with country()'s pattern,
1670                # including the mixed-notation branch for ::ffff:a.b.c.d addresses.
1671
6
4
                my $ip;
1672
6
21
                if($raw_ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/a) {
1673
4
5
                        $ip = $1;
1674                } elsif($raw_ip =~ /^([0-9a-fA-F:]{2,39}|[0-9a-fA-F:]{2,30}:(?:\d{1,3}\.){3}\d{1,3})$/a) {
1675
0
0
                        $ip = $1;
1676                } else {
1677
2
5
                        $self->_warn({ warning => "$raw_ip isn't a valid IP address" });
1678
2
4
                        return;
1679                }
1680
1681
4
5
                if($self->{_have_geoip} == $GEO_UNKNOWN) {
1682
1
5
                        $self->_load_geoip();
1683                }
1684
4
12
                if($self->{_have_geoip} == $GEO_PRESENT) {
1685
1
1
2
2
                        eval { $self->{_timezone} = $self->{_geoip}->time_zone($ip) };
1686                }
1687
1688
4
10
                unless($self->{_timezone}) {
1689
3
3
0
3
131
0
                        if(eval { require LWP::Simple::WithCache; require JSON::Parse }) {
1690
0
0
                                $self->_debug("Look up $ip on ip-api.com");
1691
1692
0
0
                                if(my $data = LWP::Simple::WithCache::get("http://ip-api.com/json/$ip")) {
1693
0
0
0
0
                                        eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} };
1694
0
0
                                        $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@;
1695                                }
1696
3
0
116
0
                        } elsif(eval { require LWP::Simple; require JSON::Parse }) {
1697
0
0
                                $self->_debug("Look up $ip on ip-api.com");
1698
1699
0
0
                                if(my $data = LWP::Simple::get("http://ip-api.com/json/$ip")) {
1700
0
0
0
0
                                        eval { $self->{_timezone} = JSON::Parse::parse_json($data)->{'timezone'} };
1701
0
0
                                        $self->_warn({ warning => "ip-api.com returned unparseable JSON: $@" }) if $@;
1702                                }
1703                        } else {
1704                                # Neither LWP variant is available — degrade gracefully rather than
1705                                # killing the entire request with a croak; caller can check for undef.
1706
3
5
                                $self->_warn({ warning => 'LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com' });
1707                        }
1708                }
1709        } else {
1710                # Local connection — read from /etc/timezone or DateTime::TimeZone
1711
2
33
                if(CORE::open(my $fin, '<', '/etc/timezone')) {
1712
2
345
                        my $tz = <$fin>;
1713
2
3
                        chomp $tz;
1714
2
13
                        $self->{_timezone} = $tz;
1715                } else {
1716
0
0
                        $self->{_timezone} = DateTime::TimeZone::Local->TimeZone()->name();
1717                }
1718        }
1719
1720        # Validate the timezone string against a permissive but bounded IANA pattern.
1721        # Rejects XSS payloads (e.g. "Europe/London<script>...") from hostile JSON
1722        # responses while accepting all real IANA zone names (e.g. "America/New_York",
1723        # "Etc/GMT+8", "UTC").
1724
6
407
        if(defined($self->{_timezone}) &&
1725           $self->{_timezone} !~ /^[A-Za-z][A-Za-z0-9_+\-\/]{0,50}$/) {
1726
0
0
                $self->_warn({ warning => "Discarding malformed timezone '$self->{_timezone}'" });
1727
0
0
                delete $self->{_timezone};
1728        }
1729
1730
6
9
        unless(defined($self->{_timezone})) {
1731
3
4
                $self->_warn({ warning => "Couldn't determine the timezone" });
1732        }
1733
6
225
        return $self->{_timezone};
1734}
1735
1736 - 1753
=head2 is_rtl

Returns true (1) if the negotiated language is written right-to-left, false (0)
otherwise.  Covers Arabic, Hebrew, Persian, Urdu, Yiddish, Dhivehi, Pashto,
Sindhi, Uyghur, and Kurdish.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ar';
    my $l = CGI::Lingua->new(supported => ['ar', 'en']);
    print $l->is_rtl();   # 1

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: 1 | 0

=cut
1754
1755sub is_rtl
1756{
1757
6
34
        my $self = shift;
1758
6
7
        return $RTL_LANGS{$self->language_code_alpha2() // ''} ? 1 : 0;
1759}
1760
1761 - 1777
=head2 text_direction

Returns C<'rtl'> or C<'ltr'> for the negotiated language, suitable for direct
use as an HTML C<dir> attribute value.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'he';
    my $l = CGI::Lingua->new(supported => ['he', 'en']);
    print qq(<html dir="} . $l->text_direction() . qq(">);   # dir="rtl"

=head3 API SPECIFICATION

    Input:  none beyond $self
    Returns: 'rtl' | 'ltr'

=cut
1778
1779sub text_direction
1780{
1781
2
16
        my $self = shift;
1782
2
2
        return $self->is_rtl() ? 'rtl' : 'ltr';
1783}
1784
1785 - 1811
=head2 plural_category

Returns the CLDR plural category for the integer C<$n> in the negotiated
language.  The returned string is one of C<'zero'>, C<'one'>, C<'two'>,
C<'few'>, C<'many'>, or C<'other'>.

Rules are embedded for ~70 languages including Arabic (6 forms), Slavic
languages (3-4 forms), Celtic languages (up to 6 forms), and Hebrew, Maltese,
Romanian, Latvian, Lithuanian, and Slovenian.  Languages not in the table fall
back to the English rule (n == 1 => C<'one'>, else C<'other'>).

For fractional numbers or full CLDR v42+ accuracy, use C<Locale::CLDR>.

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ru';
    my $l = CGI::Lingua->new(supported => ['ru']);
    print $l->plural_category(1);    # "one"
    print $l->plural_category(3);    # "few"
    print $l->plural_category(11);   # "many"

=head3 API SPECIFICATION

    Input:  $n - non-negative integer (fractional values are truncated)
    Returns: Str - one of zero/one/two/few/many/other

=cut
1812
1813# CLDR plural category rules (https://unicode.org/cldr/charts/42/supplemental/language_plural_rules.html).
1814# Values are coderefs: ($n) → category string.  Languages absent from this
1815# table fall back to the standard one/other rule inside plural_category().
1816my %PLURAL_RULES = (
1817
1818        # ── No plural distinction (always 'other') ────────────────────────────
1819        (map { $_ => sub { 'other' } }
1820                qw(az bm bo dz id ig ii in ja jbo jv jw kde kea km ko lkt lo ms
1821                   my nqo root sah ses sg th to vi wo yo zh)),
1822
1823        # ── Standard: n == 1 → 'one', else 'other' ───────────────────────────
1824        (map { $_ => sub { int($_[0]) == 1 ? 'one' : 'other' } }
1825                qw(af an ast bg bn brx ca cgg da de el en eo es et eu fi
1826                   gl gsw gu ha haw hu ia it kcg kk kl lb lg mas ml mn mr
1827                   nb nd ne nl nn nyn om or pa pap rm rof rwk saq seh sn so
1828                   sq ss ssy st sv sw ta te teo tig tk tl tn ts ur wae xh xog)),
1829
1830        # ── French / Portuguese-BR: n ≤ 1 → 'one' ────────────────────────────
1831        (map { $_ => sub { int($_[0]) <= 1 ? 'one' : 'other' } } qw(fr pt_BR)),
1832
1833        # ── Arabic: zero/one/two/few/many/other ───────────────────────────────
1834        ar => sub {
1835                my $n    = int($_[0]);
1836                my $m100 = $n % 100;
1837                return 'zero'  if $n == 0;
1838                return 'one'   if $n == 1;
1839                return 'two'   if $n == 2;
1840                return 'few'   if $m100 >= 3  && $m100 <= 10;
1841                return 'many'  if $m100 >= 11 && $m100 <= 99;
1842                return 'other';
1843        },
1844
1845        # ── Hebrew: one/two/many/other ────────────────────────────────────────
1846        he => sub {
1847                my $n = int($_[0]);
1848                return 'one'  if $n == 1;
1849                return 'two'  if $n == 2;
1850                return 'many' if $n != 0 && $n % 10 == 0;
1851                return 'other';
1852        },
1853
1854        # ── Russian / Ukrainian / Belarusian: one/few/many ────────────────────
1855        (map { $_ => sub {
1856                my $n    = int($_[0]);
1857                my $m10  = $n % 10;
1858                my $m100 = $n % 100;
1859                return 'one' if $m10 == 1 && $m100 != 11;
1860                return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20);
1861                return 'many';
1862        } } qw(ru uk be)),
1863
1864        # ── Polish: one/few/many ──────────────────────────────────────────────
1865        pl => sub {
1866                my $n    = int($_[0]);
1867                my $m10  = $n % 10;
1868                my $m100 = $n % 100;
1869                return 'one' if $n == 1;
1870                return 'few' if $m10 >= 2 && $m10 <= 4 && ($m100 < 10 || $m100 >= 20);
1871                return 'many';
1872        },
1873
1874        # ── Czech / Slovak: one/few/other ────────────────────────────────────
1875        (map { $_ => sub {
1876                my $n = int($_[0]);
1877                return 'one' if $n == 1;
1878                return 'few' if $n >= 2 && $n <= 4;
1879                return 'other';
1880        } } qw(cs sk)),
1881
1882        # ── Romanian: one/few/other ───────────────────────────────────────────
1883        ro => sub {
1884                my $n    = int($_[0]);
1885                my $m100 = $n % 100;
1886                return 'one' if $n == 1;
1887                return 'few' if $n == 0 || ($m100 >= 1 && $m100 <= 19);
1888                return 'other';
1889        },
1890
1891        # ── Latvian: zero/one/other ───────────────────────────────────────────
1892        lv => sub {
1893                my $n    = int($_[0]);
1894                my $m10  = $n % 10;
1895                my $m100 = $n % 100;
1896                return 'zero'  if $m10 == 0 || ($m100 >= 11 && $m100 <= 19);
1897                return 'one'   if $m10 == 1 && $m100 != 11;
1898                return 'other';
1899        },
1900
1901        # ── Lithuanian: one/few/other ─────────────────────────────────────────
1902        lt => sub {
1903                my $n    = int($_[0]);
1904                my $m10  = $n % 10;
1905                my $m100 = $n % 100;
1906                return 'one' if $m10 == 1 && ($m100 < 10 || $m100 >= 20);
1907                return 'few' if $m10 >= 2 && ($m100 < 10 || $m100 >= 20);
1908                return 'other';
1909        },
1910
1911        # ── Slovenian: one/two/few/other ──────────────────────────────────────
1912        sl => sub {
1913                my $m100 = int($_[0]) % 100;
1914                return 'one'   if $m100 == 1;
1915                return 'two'   if $m100 == 2;
1916                return 'few'   if $m100 == 3 || $m100 == 4;
1917                return 'other';
1918        },
1919
1920        # ── Welsh: zero/one/two/few/many/other ───────────────────────────────
1921        cy => sub {
1922                my $n = int($_[0]);
1923                return 'zero'  if $n == 0;
1924                return 'one'   if $n == 1;
1925                return 'two'   if $n == 2;
1926                return 'few'   if $n == 3;
1927                return 'many'  if $n == 6;
1928                return 'other';
1929        },
1930
1931        # ── Irish: one/two/few/many/other ────────────────────────────────────
1932        ga => sub {
1933                my $n = int($_[0]);
1934                return 'one'  if $n == 1;
1935                return 'two'  if $n == 2;
1936                return 'few'  if $n >= 3 && $n <= 6;
1937                return 'many' if $n >= 7 && $n <= 10;
1938                return 'other';
1939        },
1940
1941        # ── Maltese: one/two/few/many/other ──────────────────────────────────
1942        mt => sub {
1943                my $n    = int($_[0]);
1944                my $m100 = $n % 100;
1945                return 'one'  if $n == 1;
1946                return 'two'  if $n == 2;
1947                return 'few'  if $n == 0 || ($m100 >= 3  && $m100 <= 10);
1948                return 'many' if $m100 >= 11 && $m100 <= 19;
1949                return 'other';
1950        },
1951);
1952
1953sub plural_category
1954{
1955
16
65
        my ($self, $n) = @_;
1956
16
15
        croak('plural_category: $n must be defined') unless defined $n;
1957
16
15
        my $code = $self->language_code_alpha2() // return 'other';
1958
15
0
16
0
        my $rule = $PLURAL_RULES{$code} // sub { int($_[0]) == 1 ? 'one' : 'other' };
1959
15
19
        return $rule->($n);
1960}
1961
1962 - 2001
=head2 translation_file

Returns the filesystem path to the best matching translation file for the
negotiated language in the given directory.

The lookup tries (in order):

=over 4

=item 1. C<$dir/$lang-$sublang.$ext>  (e.g. C<en-gb.json>)

=item 2. C<$dir/$lang.$ext>           (e.g. C<en.json>)

=back

Returns C<undef> if no matching file exists.

=head3 API SPECIFICATION

    Input:
      $dir - Str   path to the directory containing translation files
      $ext - Str   file extension without leading dot (default: 'json')
    Returns: Str (absolute or relative path) | undef

=head3 EXAMPLE

    local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
    my $l = CGI::Lingua->new(supported => ['en-gb', 'en']);
    my $path = $l->translation_file('/var/www/i18n');
    # Returns '/var/www/i18n/en-gb.json' if it exists,
    # then '/var/www/i18n/en.json', or undef.

    # Custom extension:
    my $path = $l->translation_file('/var/www/i18n', 'po');

=head3 MESSAGES

    (none - returns undef silently when no file is found)

=cut
2002
2003sub translation_file
2004{
2005
6
344
        my ($self, $dir, $ext) = @_;
2006
6
9
        return unless defined $dir;
2007
2008        # Reject traversal attempts in the directory argument.  A real translation
2009        # directory never needs '..', null bytes, or other shell metacharacters.
2010        # The caller is responsible for not passing user-controlled data as $dir,
2011        # but we guard here as a defence-in-depth measure.
2012
5
32
        if($dir =~ /\.\./ || $dir =~ /\x00/) {
2013
0
0
                $self->_warn({ warning => "translation_file: unsafe directory '$dir' rejected" });
2014
0
0
                return;
2015        }
2016
2017
5
8
        $ext //= 'json';
2018
5
6
        $ext =~ s/^\.//;    # accept '.json' or 'json'
2019
2020        # Reject extensions containing path-traversal sequences or shell metacharacters.
2021        # Valid extensions are word characters and hyphens only (e.g. 'json', 'po', 'yml').
2022
5
16
        unless($ext =~ /^[A-Za-z0-9\-]+$/) {
2023
1
3
                $self->_warn({ warning => "translation_file: unsafe extension '$ext' rejected" });
2024
1
124
                return;
2025        }
2026
2027
4
4
        my @candidates;
2028
4
7
        if(my $sub = $self->sublanguage_code_alpha2()) {
2029
0
0
                push @candidates, $self->language_code_alpha2() . '-' . $sub;
2030        }
2031
4
4
        push @candidates, $self->language_code_alpha2()
2032                if defined $self->language_code_alpha2();
2033
2034
4
4
        for my $code (@candidates) {
2035
4
4
                my $path = "$dir/$code.$ext";
2036
4
30
                return $path if -e $path;
2037        }
2038
1
2
        return;
2039}
2040
2041# ── _code2language ────────────────────────────────────────────────────────
2042# Purpose:      Translate a 2-char language code to its English name, with
2043#               optional CHI caching.
2044# Entry:        $code — 2-char ISO 639-1 code; must be defined and non-empty.
2045# Exit:         Human-readable language name string, or undef.
2046# Side Effects: Reads/writes cache.
2047sub _code2language
2048{
2049
152
660
        my ($self, $code) = @_;
2050
2051
152
157
        return unless $code;
2052
150
141
        if(defined($self->{_country})) {
2053
3
6
                $self->_debug("_code2language $code, country ", $self->{_country});
2054        } else {
2055
147
181
                $self->_debug("_code2language $code");
2056        }
2057
2058
150
4500
        unless($self->{_cache}) {
2059
128
165
                return Locale::Language::code2language($code);
2060        }
2061
2062
22
40
        if(my $from_cache = $self->{_cache}->get($CACHE_NS . "code2language:$code")) {
2063
7
556
                $self->_trace("_code2language found in cache $from_cache");
2064
7
145
                return $from_cache;
2065        }
2066
2067        # Compute, cache, then return the value separately —
2068        # CHI->set() is not guaranteed to return the stored value across all drivers
2069
15
595
        $self->_trace('_code2language not in cache, storing');
2070
15
485
        my $name = Locale::Language::code2language($code);
2071
15
363272
        if(defined $name) {
2072
14
38
                $self->{_cache}->set($CACHE_NS . "code2language:$code", $name, $CACHE_TTL_LONG);
2073        }
2074
15
1988
        return $name;
2075}
2076
2077# ── _code2country ─────────────────────────────────────────────────────────
2078# Purpose:      Translate a 2-char country code to a Locale::Object::Country
2079#               object, suppressing the expected "No result found" warning.
2080# Entry:        $code — 2-char ISO 3166-1 alpha-2 code (any case).
2081# Exit:         Locale::Object::Country object, or undef.
2082# Side Effects: None beyond the Locale::Object::Country look-up.
2083sub _code2country
2084{
2085
90
444
        my ($self, $code) = @_;
2086
2087
90
75
        return unless $code;
2088
88
104
        if($self->{_country}) {
2089
13
17
                $self->_trace(">_code2country $code, country ", $self->{_country});
2090        } else {
2091
75
104
                $self->_trace(">_code2country $code");
2092        }
2093
2094
88
2591
        my $rc;
2095        {
2096                # Scope the signal handler tightly — only suppress the one known-harmless warning
2097
88
55
                local $SIG{__WARN__} = sub {
2098
14
77349
                        warn $_[0] unless $_[0] =~ /No result found in country table/;
2099
88
224
                };
2100
88
264
                $rc = Locale::Object::Country->new(code_alpha2 => $code);
2101        }
2102
88
2481340
        $self->_trace('<_code2country ', $code || 'undef');
2103
88
3209
        return $rc;
2104}
2105
2106# ── _code2countryname ─────────────────────────────────────────────────────
2107# Purpose:      Translate a 2-char country code to its English name string,
2108#               with optional CHI caching.
2109# Entry:        $code — 2-char ISO 3166-1 alpha-2 code.
2110# Exit:         Country name string, or undef.
2111# Side Effects: Reads/writes cache.
2112sub _code2countryname
2113{
2114
51
308
        my ($self, $code) = @_;
2115
2116
51
61
        return unless $code;
2117
49
69
        $self->_trace(">_code2countryname $code");
2118
2119
49
1621
        unless($self->{_cache}) {
2120
42
54
                my $country = $self->_code2country($code);
2121
42
85
                return defined($country) ? $country->name : undef;
2122        }
2123
2124
7
13
        if(my $from_cache = $self->{_cache}->get($CACHE_NS . "code2countryname:$code")) {
2125
1
72
                $self->_trace("_code2countryname found in cache $from_cache");
2126
1
34
                return $from_cache;
2127        }
2128
2129
6
238
        if(my $country = $self->_code2country($code)) {
2130
5
10
                $self->_debug('_code2countryname not in cache, storing');
2131
5
137
                my $name = $country->name();
2132
5
20
                $self->_trace('<_code2countryname ', $name);
2133                # Store then return explicitly — don't rely on set() return value
2134
5
127
                $self->{_cache}->set($CACHE_NS . "code2countryname:$code", $name, $CACHE_TTL_LONG);
2135
5
713
                return $name;
2136        }
2137
1
2
        $self->_trace('<_code2countryname undef');
2138
1
31
        return;
2139}
2140
2141# ── _log ──────────────────────────────────────────────────────────────────
2142# Purpose:      Append a message to $self->{messages} and forward to the
2143#               optional logger object.
2144# Entry:        $level — log level string (debug/info/notice/warn/trace/error);
2145#               @messages — one or more strings to concatenate.
2146# Exit:         void
2147# Side Effects: Mutates $self->{messages}; calls logger method if set.
2148sub _log
2149{
2150
2351
3109
        my ($self, $level, @messages) = @_;
2151
2152
2351
2972
        return unless ref($self) && scalar(@messages);
2153
2154
2349
2799
        my $text = join('', grep defined, @messages);
2155
2349
1737
        return unless length($text);
2156
2348
2348
1206
2962
        push @{$self->{'messages'}}, { level => $level, message => $text };
2157
2158
2348
2190
        if(my $logger = $self->{'logger'}) {
2159
2346
2615
                $logger->$level($text);
2160        }
2161}
2162
2163
1192
1192
695
987
sub _debug  { my $self = shift; $self->_log('debug',  @_) }
2164
4
4
1037
6
sub _info   { my $self = shift; $self->_log('info',   @_) }
2165
4
4
996
11
sub _notice { my $self = shift; $self->_log('notice', @_) }
2166
1142
1142
1750
1066
sub _trace  { my $self = shift; $self->_log('trace',  @_) }
2167
2168# ── _warn ─────────────────────────────────────────────────────────────────
2169# Purpose:      Emit a warning through the logger (if set) or via Carp::carp.
2170# Entry:        A single hashref argument: { warning => 'message text' }.
2171#               All callers MUST use this structured form — plain-string calls
2172#               silently lose the message when no logger is configured.
2173# Exit:         void
2174# Side Effects: Calls logger->warn() or carp().
2175sub _warn
2176{
2177
51
117
        my $self = shift;
2178
2179        # Parse once; both branches need the same $msg.
2180
51
64
        my $params = Params::Get::get_params('warning', @_);
2181
51
764
        my $msg    = (ref($params) ? $params->{'warning'} : undef) // join('', grep defined, @_);
2182
2183
51
52
        if(defined($self->{'logger'})) {
2184
49
64
                $self->{'logger'}->warn($msg);
2185        } else {
2186
2
3
                $self->_log('warn', $msg);
2187
2
3
                carp($msg);
2188        }
2189}
2190
2191 - 2431
=head1 LIMITATIONS

=over 4

=item * B<is_rtl() covers primary-script RTL languages only>

C<is_rtl()> returns true for the 10 ISO 639-1 codes whose overwhelmingly
dominant script is right-to-left.  Languages with script variants (e.g.
Azerbaijani C<az>, which uses Latin in modern Azerbaijan but Arabic in Iran)
are treated as LTR.  If you serve content in multiple scripts of the same
language, inspect the sublanguage or Accept-Language header directly.

=item * B<plural_category() uses embedded CLDR rules, not Locale::CLDR>

The embedded rules cover ~70 languages and truncate fractional C<$n> to an
integer.  For full CLDR v42 accuracy (including fractional forms and
languages not in the table) install and use C<Locale::CLDR> directly.

=item * B<Logger must be a blessed object>

The C<logger> parameter is documented as accepting a code ref, array ref, or
filename, but the current implementation calls C<< $logger->$level() >> and will
die on non-blessed values.  Wrap alternative logger types in a
C<Log::Abstraction> instance before passing them to C<new()>.

=item * B<es-419 sublanguage returns undef>

Three-part regional codes such as C<es-419> (Latin American Spanish) do not
resolve to a C<sublanguage()> value because ISO 3166-1 does not define '419'.
This is a known limitation of the Locale::Object layer.

=item * B<Whois lookups are slow and unreliable>

Without C<IP::Country>, C<Geo::IP>, or C<Geo::IPfree> installed, C<country()>
falls back to Whois queries against live RIPE/ARIN/IANA servers.  These can
time out under load.  Install at least one local geo-database module and enable
the CHI cache to avoid this.

=item * B<Private methods are accessible from outside the package>

The C<_*> methods use the naming convention for privacy but Perl does not enforce
it.  C<Sub::Private> or C<Sub::Protected> should be added once all white-box
tests (C<t/function.t>, C<t/extended_tests.t>) are updated to use the public API
exclusively.

=item * B<IPv4-mapped IPv6 addresses are normalised to IPv4>

C<REMOTE_ADDR> values in the form C<::ffff:a.b.c.d> (RFC 4291 section 2.5.5)
are silently rewritten to the embedded C<a.b.c.d> IPv4 address before any
geo-lookup.  This is correct for country detection purposes but means the raw
address string is not preserved in cache keys or log messages.

=item * B<EU country code is irresolvable (with one exception)>

IP addresses that Whois reports as country C<EU> are mapped to C<'Unknown'>
unless they fall within Baidu's known subnet (RT-86809).  There is no ISO
3166-1 country code for the European Union.

=item * B<country() does not cache undef results>

When C<country()> cannot determine a country (private IPs, loopback,
unresolvable addresses), it returns C<undef> without storing the result.  A
second call on the same object repeats the full validation pipeline.  This is
intentional: C<country()> reads C<REMOTE_ADDR> at call time rather than at
construction time, so caching C<undef> would return a wrong answer if
C<REMOTE_ADDR> changes between calls.  In practice this is rarely a problem
because C<country()> is called once per request and CGI applications typically
create a fresh object per request.

=back

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 BUGS

Please report any bugs or feature requests to the author.

If C<HTTP_ACCEPT_LANGUAGE> contains a sub-tag with a 3-digit UN M.49 region
code (e.g. C<es-419> for Latin American Spanish), C<sublanguage()> returns
C<undef> because ISO 3166-1 does not define numeric codes.

Please report any bugs or feature requests to C<bug-cgi-lingua at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=CGI-Lingua>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

Uses L<I18N::AcceptLanguage> to find the highest priority accepted language.
This means that if you support languages at a lower priority, it may be missed.

=head1 SEE ALSO

=over 4

=item * L<Configure an Object at Runtime|Object::Configure>

=item * L<Test Dashboard|https://nigelhorne.github.io/CGI-Lingua/coverage/>

=item * VWF - Versatile Web Framework L<https://github.com/nigelhorne/vwf>

=item * L<HTTP::BrowserDetect>

=item * L<I18N::AcceptLanguage>

=item * L<Locale::Country>

=back

=head1 SUPPORT

This module is provided as-is without any warranty.

You can find documentation for this module with the perldoc command.

    perldoc CGI::Lingua

You can also look for information at:

=over 4

=item * MetaCPAN

L<https://metacpan.org/release/CGI-Lingua>

=item * RT: CPAN's request tracker

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=CGI-Lingua>

=item * CPANTS

L<http://cpants.cpanauthors.org/dist/CGI-Lingua>

=item * CPAN Testers' Matrix

L<http://matrix.cpantesters.org/?dist=CGI-Lingua>

=item * CPAN Testers Dependencies

L<http://deps.cpantesters.org/?module=CGI::Lingua>

=back

=encoding utf-8

=head1 FORMAL SPECIFICATION

=head2 new

    new : Class × Params → CGI::Lingua
    âˆ€ p : Params • p.supported ≠ ∅ ⟹ result.language ∈ (p.supported ∪ {'Unknown'})

=head2 language

    language : CGI::Lingua → Str
    result ∈ {name(l) | l ∈ supported} ∪ {'Unknown'}

=head2 sublanguage

    sublanguage : CGI::Lingua -> Str | undef
    result = country_name(sublanguage_code_alpha2(self))
             when sublanguage_code_alpha2(self) is defined,
             undef otherwise

=head2 language_code_alpha2

    language_code_alpha2 : CGI::Lingua -> Str(2) | undef
    result = base_code(matched_supported_entry)
             when a supported language was matched, undef otherwise

=head2 sublanguage_code_alpha2

    sublanguage_code_alpha2 : CGI::Lingua -> Str(2) | undef
    result = variety_code(matched_supported_entry) | undef

=head2 requested_language

    requested_language : CGI::Lingua -> Str
    result = name(base) + " (" + name(variety) + ")"
             when variety is known,
           = name(base)   when no variety,
           = 'Unknown'    when no language detected

=head2 country

    country : CGI::Lingua -> Str(2,lowercase) | undef
    -- 'Unknown' returned only in the EU/Baidu special case
    result = lc(code) where code satisfies ISO 3166-1 alpha-2
             | undef when IP is private, loopback, or unresolvable

=head2 locale

    locale : CGI::Lingua -> Locale::Object::Country | undef
    -- Best-guess detection; not guaranteed accurate.
    result = first defined value from:
        1. UA parenthetical language tag
        2. HTTP::BrowserDetect country
        3. country() IP lookup
        4. GEOIP_COUNTRY_CODE env var

=head2 time_zone

    time_zone : CGI::Lingua -> Str | undef
    result is an IANA timezone name (e.g. 'Europe/London') or undef

=head2 is_rtl

    is_rtl : CGI::Lingua → Bool
    is_rtl(s) ≙ language_code_alpha2(s) ∈ RTL_LANGS

=head2 text_direction

    text_direction : CGI::Lingua → {'rtl', 'ltr'}
    text_direction(s) ≙ is_rtl(s) ? 'rtl' : 'ltr'

=head2 plural_category

    plural_category : CGI::Lingua x N -> PluralCategory
    plural_category(s, n) = PLURAL_RULES[language_code_alpha2(s)](trunc(n))
    -- Falls back to English rule (n=1 -> 'one'; else 'other')
    -- when language_code_alpha2(s) is undef or not in the rules table.

=head2 translation_file

    translation_file : CGI::Lingua × Path × Ext → Path | undef
    translation_file(s, d, e) ≙
      first p ∈ candidates(s) • ∃ file d/p.e
      where candidates(s) = [lang(s)-sublang(s), lang(s)] \ {undef}

=head1 ACKNOWLEDGEMENTS

=head1 LICENSE AND COPYRIGHT

Copyright 2010-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
2432
24331; # End of CGI::Lingua