lp1863252 toward geosort
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / WWW / EGWeb.pm
1 package OpenILS::WWW::EGWeb;
2 use strict; use warnings;
3 use Template;
4 use XML::Simple;
5 use XML::LibXML;
6 use File::stat;
7 use Encode;
8 use Apache2::Const -compile => qw(OK DECLINED HTTP_INTERNAL_SERVER_ERROR HTTP_NOT_FOUND HTTP_GONE);
9 use Apache2::Log;
10 use OpenSRF::EX qw(:try);
11 use OpenSRF::AppSession;
12 use OpenILS::Utils::CStoreEditor q/:funcs/;
13 use List::MoreUtils qw/uniq/;
14
15 use constant OILS_HTTP_COOKIE_SKIN => 'eg_skin';
16 use constant OILS_HTTP_COOKIE_THEME => 'eg_theme';
17 use constant OILS_HTTP_COOKIE_LOCALE => 'eg_locale';
18
19 # cache string bundles
20 my %registered_locales;
21
22 # cache template path -r tests
23 my %vhost_path_cache;
24
25 # cache template processors by vhost
26 my %vhost_processor_cache;
27
28 my $bootstrap_config;
29 my @context_loaders_to_preinit = ();
30 my %locales_to_preinit = ();
31
32 sub import {
33     my ($self, $bootstrap_config, $loaders, $locales) = @_;
34     @context_loaders_to_preinit = split /\s+/, $loaders, -1 if defined($loaders);
35     %locales_to_preinit = map { $_ => parse_eg_locale($_) }
36                           split /\s+/, $locales, -1 if defined($locales);
37 }
38
39 sub child_init {
40     OpenSRF::System->bootstrap_client(config_file => $bootstrap_config);
41     my $idl = OpenSRF::Utils::SettingsClient->new->config_value("IDL");
42     Fieldmapper->import(IDL => $idl);
43     foreach my $loader (@context_loaders_to_preinit) {
44         eval {
45             $loader->use;
46             $loader->child_init(%locales_to_preinit);
47         };
48     }
49     return Apache2::Const::OK;
50 }
51
52 sub log_handler {
53     my $r = shift;
54
55     my @params_to_redact = uniq $r->dir_config->get('OILSUrlParamToRedact');
56     my $re = '('. join('|', map { quotemeta($_) } @params_to_redact) . ')=(?:[^&;]*)';
57
58     my $args = $r->args();
59     $args =~ s/$re/$1=[REDACTED]/g;
60     $r->args($args);
61     my $req = $r->the_request(); # munging args doesn't update the
62                                  # original requested URI
63     $req =~ s/$re/$1=[REDACTED]/g;
64     $r->the_request($req);
65
66     if ($r->headers_in->{Referer}) {
67         $r->headers_in->{Referer} =~ s/$re/$1=[REDACTED]/g;
68     }
69
70     return Apache2::Const::OK;
71 }
72
73 sub handler {
74     my $r = shift;
75     my $stat = handler_guts($r);
76
77     # other opensrf clients share this apache process,
78     # so it's critical to reset the locale after each
79     # response is handled, lest the other clients 
80     # adopt our temporary, global locale value.
81     OpenSRF::AppSession->reset_locale;
82     return $stat;
83 }
84     
85 sub handler_guts {
86     my $r = shift;
87     my $ctx = load_context($r);
88     my $base = $ctx->{base_path};
89
90     my($template, $page_args, $as_xml) = find_template($r, $base, $ctx);
91     $ctx->{page_args} = $page_args;
92
93     my $stat = run_context_loader($r, $ctx);
94
95     # Handle deleted or never existing records a little more gracefully.
96     # For these two special cases, we set the status so that the request
97     # header will contain the appropriate HTTP status code, but reset the
98     # status so that Apache will continue to process the request and provide
99     # more than just the raw HTTP error page.
100     if ($stat == Apache2::Const::HTTP_GONE || $stat == Apache2::Const::HTTP_NOT_FOUND) {
101         $r->status($stat);
102         $stat = Apache2::Const::OK;
103     }   
104     return $stat unless $stat == Apache2::Const::OK;
105
106     # emit context as JSON if handler requests
107     if ($ctx->{json_response}) {
108         $r->content_type("application/json; charset=utf-8");
109         $r->headers_out->add("cache-control" => "no-store, no-cache, must-revalidate");
110         $r->headers_out->add("expires" => "-1");
111         if ($ctx->{json_reponse_cookie}) {
112             $r->headers_out->add('Set-Cookie' => $ctx->{json_reponse_cookie})
113         }
114         $r->print(OpenSRF::Utils::JSON->perl2JSON($ctx->{json_response}));
115         return Apache2::Const::OK;
116     }
117
118     return Apache2::Const::DECLINED unless $template;
119
120     my $text_handler = set_text_handler($ctx, $r);
121
122     my $processor_key = $as_xml ? 'xml:' : 'text:';                 # separate by XML strictness
123     $processor_key .= $r->hostname.':';                         # ... and vhost
124     $processor_key .= $r->dir_config('OILSWebContextLoader').':';   # ... and context loader
125     $processor_key .= $ctx->{locale};                               # ... and locale
126     # NOTE: context loader and vhost together imply template path and debug template values
127
128     my $tt = $vhost_processor_cache{$processor_key} || Template->new({
129         ENCODING => 'utf-8',
130         OUTPUT => ($as_xml) ?  sub { parse_as_xml($r, $ctx, @_); } : $r,
131         INCLUDE_PATH => $ctx->{template_paths},
132         DEBUG => $ctx->{debug_template},
133         (
134             $r->dir_config('OILSWebCompiledTemplateCache') ?
135                 (COMPILE_DIR => $r->dir_config('OILSWebCompiledTemplateCache')) :
136                 ()
137         ),
138         (
139             ($r->dir_config('OILSWebTemplateStatTTL') =~ /^\d+$/) ?
140                 (STAT_TTL => $r->dir_config('OILSWebTemplateStatTTL')) :
141                 ()
142         ),
143         PLUGINS => {
144             EGI18N => 'OpenILS::WWW::EGWeb::I18NFilter',
145             CGI_utf8 => 'OpenILS::WWW::EGWeb::CGI_utf8'
146         },
147         FILTERS => {
148             # Register a dynamic filter factory for our locale::maketext generator
149             l => [
150                 sub {
151                     my($ctx, @args) = @_;
152                     return sub { $text_handler->(shift(), @args); }
153                 }, 1
154             ]
155         }
156     });
157
158     if (!$tt) {
159         $r->log->error("Error creating template processor: $@");
160         return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
161     }   
162
163     $vhost_processor_cache{$processor_key} = $tt;
164     $ctx->{encode_utf8} = sub {return encode_utf8(shift())};
165
166     unless($tt->process($template, {ctx => $ctx, ENV => \%ENV, l => $text_handler}, $r)) {
167         $r->log->warn('egweb: template error: ' . $tt->error);
168         return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
169     }
170
171     return Apache2::Const::OK;
172 }
173
174 sub set_text_handler {
175     my $ctx = shift;
176     my $r = shift;
177
178     my $locale = $ctx->{locale};
179
180     $r->log->debug("egweb: messages locale = $locale");
181
182     return sub {
183         my $lh = OpenILS::WWW::EGWeb::I18N->get_handle($locale) 
184             || OpenILS::WWW::EGWeb::I18N->new;
185         return $lh->maketext(@_);
186     };
187 }
188
189
190
191 sub run_context_loader {
192     my $r = shift;
193     my $ctx = shift;
194
195     my $stat = Apache2::Const::OK;
196
197     my $loader = $r->dir_config('OILSWebContextLoader');
198     return $stat unless $loader;
199
200     eval {
201         $loader->use;
202         $stat = $loader->new($r, $ctx)->load;
203     };
204
205     if($@) {
206         $r->log->error("egweb: Context Loader error: $@");
207         return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
208     }
209
210     $r->log->debug("egweb: context loader resulted in status $stat");
211     return $stat;
212 }
213
214 sub parse_as_xml {
215     my $r = shift;
216     my $ctx = shift;
217     my $data = shift;
218
219     my $success = 0;
220
221     try { 
222         my $doc = XML::LibXML->new->parse_string($data); 
223         $data = $doc->documentElement->toStringC14N;
224         $data = $ctx->{final_dtd} . "\n" . $data;
225         $success = 1;
226     } otherwise {
227         my $e = shift;
228         my $err = "Invalid XML: $e";
229         $r->log->error("egweb: $err");
230         $r->content_type('text/plain; encoding=utf8');
231         $r->print("\n$err\n\n$data");
232     };
233
234     $r->print($data) if ($success);
235 }
236
237 sub load_context {
238     my $r = shift;
239     my $cgi = CGI->new;
240     my $ctx = {}; # new context for each page load
241
242     $ctx->{base_path} = $r->dir_config('OILSWebBasePath');
243     $ctx->{web_dir} = $r->dir_config('OILSWebWebDir');
244     $ctx->{debug_template} = ($r->dir_config('OILSWebDebugTemplate') =~ /true/io) ? 1 : 0;
245     $ctx->{hostname} = $r->hostname;
246     $ctx->{media_prefix} = $r->dir_config('OILSWebMediaPrefix') || $ctx->{hostname};
247     $ctx->{base_url} = $cgi->url(-base => 1);
248     $ctx->{skin} = $cgi->cookie(OILS_HTTP_COOKIE_SKIN) || 'default';
249     $ctx->{theme} = $cgi->cookie(OILS_HTTP_COOKIE_THEME) || 'default';
250     $ctx->{proto} = $cgi->https ? 'https' : 'http';
251     $ctx->{ext_proto} = $ctx->{proto};
252     my $default_locale = $r->dir_config('OILSWebDefaultLocale') || 'en_us';
253
254     my @template_paths = uniq $r->dir_config->get('OILSWebTemplatePath');
255     $ctx->{template_paths} = [ reverse @template_paths ];
256
257     my @locales = $r->dir_config->get('OILSWebLocale');
258     load_locale_handlers($ctx, @locales);
259
260     $ctx->{locales} = \%registered_locales;
261
262     # Set a locale cookie if the requested locale is valid
263     my $set_locale = $cgi->param('set_eg_locale') || '';
264     if (!(grep {$_ eq $set_locale} keys %registered_locales)) {
265         $set_locale = '';
266     } else {
267         my $slc = $cgi->cookie({
268             '-name' => OILS_HTTP_COOKIE_LOCALE,
269             '-value' => $set_locale,
270             '-expires' => '+10y'
271         });
272         $r->headers_out->add('Set-Cookie' => $slc);
273     }
274
275     $ctx->{locale} = $set_locale ||
276         $cgi->cookie(OILS_HTTP_COOKIE_LOCALE) || $default_locale ||
277         parse_accept_lang($r->headers_in->get('Accept-Language'));
278
279     # set the editor default locale for each page load
280     my $ses_locale = parse_eg_locale($ctx->{locale});
281     OpenSRF::AppSession->default_locale($ses_locale);
282     # give templates access to the en-US style locale
283     $ctx->{eg_locale} = $ses_locale;
284
285     my $mprefix = $ctx->{media_prefix};
286     if($mprefix and $mprefix !~ /^http/ and $mprefix !~ /^\//) {
287         # if a hostname is provided /w no protocol, match the protocol to the current page
288         $ctx->{media_prefix} = ($cgi->https) ? "https://$mprefix" : "http://$mprefix";
289     }
290
291     return $ctx;
292 }
293
294 # turn Accept-Language into something EG can understand
295 # TODO: try all langs, not just the first
296 sub parse_accept_lang {
297     my $al = shift;
298     return undef unless $al;
299     my ($locale) = split(/,/, $al);
300     ($locale) = split(/;/, $locale);
301     return undef unless $locale;
302     $locale =~ s/-/_/og;
303     return $locale;
304 }
305
306 # Accept-Language uses locales like 'en', 'fr', 'fr_fr', while Evergreen
307 # internally uses 'en-US', 'fr-CA', 'fr-FR' (always with the 2 lowercase,
308 # hyphen, 2 uppercase convention)
309 sub parse_eg_locale {
310     my $ua_locale = shift || 'en_us';
311
312     $ua_locale =~ m/^(..).?(..)?$/;
313     my $lang_code = lc($1);
314     my $region_code = $2 ? uc($2) : uc($1);
315     return "$lang_code-$region_code";
316 }
317
318 # Given a URI, finds the configured template and any extra page 
319 # arguments (trailing path info).  Any extra data is returned
320 # as page arguments, in the form of an array, one item per 
321 # /-separated URI component
322 sub find_template {
323     my $r = shift;
324     my $base = shift;
325     my $ctx = shift;
326     my $path = $r->uri;
327     $path =~ s/$base\/?//og;
328     my $template = '';
329     my $page_args = [];
330     my $as_xml = $r->dir_config('OILSWebForceValidXML');
331     my $ext = $r->dir_config('OILSWebDefaultTemplateExtension');
332     my $at_index = $r->dir_config('OILSWebStopAtIndex');
333
334     $vhost_path_cache{$r->hostname} ||= {};
335     my $path_cache = $vhost_path_cache{$r->hostname};
336
337     my @parts = split('/', $path);
338     my $localpath = $path;
339
340     if ($localpath =~ m|/css/|) {
341         $r->content_type('text/css; encoding=utf8');
342     } else {
343         $r->content_type('text/html; encoding=utf8');
344     }
345
346     my @args;
347     while(@parts) {
348         last unless $localpath;
349         for my $tpath (@{$ctx->{template_paths}}) {
350             my $fpath = "$tpath/$localpath.$ext";
351             $r->log->debug("egweb: looking at possible template $fpath");
352             if ($template = $path_cache->{$fpath}) { # we've checked with -r before...
353                 next if ($template eq '0E0'); # ... and found nothing
354                 last;
355             } elsif (-r $fpath) { # or, we haven't checked, and if we find a file...
356                 $path_cache->{$fpath} = $template = "$localpath.$ext"; # ... note it
357                 last;
358             } else { # Nothing there...
359                 $path_cache->{$fpath} = '0E0'; # ... note that fact
360             }
361         }
362         last if $template and $template ne '0E0';
363
364         if ($at_index) {
365             # no matching template was found in the current directory.
366             # stop-at-index requested; see if there is an index.ext 
367             # file in the same directory instead.
368             for my $tpath (@{$ctx->{template_paths}}) {
369                 # replace the final path component with 'index'
370                 if ($localpath =~ m|/$|) {
371                     $localpath .= 'index';
372                 } else {
373                     $localpath =~ s|/[^/]+$|/index|;
374                 }
375                 my $fpath = "$tpath/$localpath.$ext";
376                 $r->log->debug("egweb: looking at possible template $fpath");
377                 if ($template = $path_cache->{$fpath}) { # See above block
378                     next if ($template eq '0E0');
379                     last;
380                 } elsif (-r $fpath) {
381                     $path_cache->{$fpath} = $template = "$localpath.$ext";
382                     last;
383                 } else {
384                     $path_cache->{$fpath} = '0E0';
385                 } 
386             }
387         }
388         last if $template and $template ne '0E0';
389
390         push(@args, pop @parts);
391         $localpath = join('/', @parts);
392     } 
393
394     $page_args = [@args];
395
396     # no template configured or found
397     if(!$template or $template eq '0E0') {
398         $r->log->debug("egweb: No template configured for path $path");
399         return ();
400     }
401
402     $r->log->debug("egweb: template = $template : page args = @$page_args");
403     return ($template, $page_args, $as_xml);
404 }
405
406 # Create an I18N sub-module for each supported locale
407 # Each module creates its own MakeText lexicon by parsing .po/.mo files
408 sub load_locale_handlers {
409     my $ctx = shift;
410     my @raw = @_;
411     my %locales = (en_us => []);
412     while (@raw) {
413         my ($l,$file) = (shift(@raw),shift(@raw)); 
414         $locales{$l} ||= [];
415         push @{$locales{$l}}, $file;
416     }
417
418     my $editor = new_editor();
419     my @locale_tags = sort { length($a) <=> length($b) } keys %locales;
420
421     for my $idx (0..$#locale_tags) {
422
423         my $tag = $locale_tags[$idx];
424         my $parent_tag = 'OpenILS::WWW::EGWeb::I18N';
425
426         my $res = $editor->json_query({
427             "from" => [
428                 "evergreen.get_locale_name",
429                 $tag
430             ]
431         });
432
433         my $locale_name = $res->[0]->{"name"} if exists $res->[0]->{"name"};
434         next unless $locale_name;
435
436         my $sub_idx = $idx;
437
438         # find the parent locale if possible.  It will be 
439         # longest left-anchored substring of the current tag
440         while( --$sub_idx >= 0 ) {
441             my $ptag = $locale_tags[$sub_idx];
442             if( substr($tag, 0, length($ptag)) eq $ptag ) {
443                 $parent_tag .= "::$ptag";
444                 last;
445             }
446         }
447
448         my $eval = <<"        EVAL"; # Dynamic part
449             package OpenILS::WWW::EGWeb::I18N::$tag;
450             use base '$parent_tag';
451         EVAL
452
453         $eval .= <<'        EVAL';
454             our %Lexicon;
455             if(@{$locales{$tag}}) {
456                 use Locale::Maketext::Lexicon {
457                     _decode => 1
458                 };
459                 use Locale::Maketext::Lexicon::Gettext;
460                 for my $messages (@{$locales{$tag}}) {
461                     if(open F, $messages) {
462                         %Lexicon = (%Lexicon, %{ Locale::Maketext::Lexicon::Gettext->parse(<F>) });
463                         close F;
464                     } else {
465                         warn "EGWeb: unable to open messages file: $messages"; 
466                     }
467                 }
468             }
469         EVAL
470
471         eval $eval;
472
473         if ($@) {
474             warn "$@\n";
475         } else {
476             $registered_locales{"$tag"} = $locale_name;
477         }
478     }
479 }
480
481
482 # base class for all supported locales
483 package OpenILS::WWW::EGWeb::I18N;
484 use base 'Locale::Maketext';
485 our %Lexicon = (_AUTO => 1);
486
487 1;