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