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