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