File Coverage

blib/lib/BusyBird/Main/PSGI/View.pm
Criterion Covered Total %
statement 238 252 94.4
branch 55 70 78.5
condition 20 27 74.0
subroutine 53 54 98.1
pod 9 9 100.0
total 375 412 91.0


line stmt bran cond sub pod time code
1             package BusyBird::Main::PSGI::View;
2 7     7   79672 use v5.8.0;
  7         19  
  7         293  
3 7     7   32 use strict;
  7         25  
  7         221  
4 7     7   123 use warnings;
  7         11  
  7         228  
5 7     7   37 use BusyBird::Util qw(set_param split_with_entities);
  7         9  
  7         385  
6 7     7   139 use Carp;
  7         14  
  7         415  
7 7     7   37 use Try::Tiny;
  7         19  
  7         425  
8 7     7   39 use Scalar::Util qw(weaken);
  7         9  
  7         365  
9 7     7   41 use JSON qw(to_json);
  7         11  
  7         57  
10 7     7   5478 use Text::Xslate qw(html_builder html_escape);
  7         71852  
  7         685  
11 7     7   74 use File::Spec;
  7         12  
  7         145  
12 7     7   42 use Encode ();
  7         15  
  7         105  
13 7     7   3935 use JavaScript::Value::Escape ();
  7         4280  
  7         188  
14 7     7   42 use DateTime::TimeZone;
  7         9  
  7         162  
15 7     7   29 use BusyBird::DateTime::Format;
  7         12  
  7         136  
16 7     7   28 use BusyBird::Log qw(bblog);
  7         8  
  7         346  
17 7     7   38 use BusyBird::SafeData qw(safed);
  7         11  
  7         353  
18 7     7   3552 use Cache::Memory::Simple;
  7         4817  
  7         194  
19 7     7   3614 use Plack::Util ();
  7         65631  
  7         226  
20 7     7   72 use Tie::IxHash;
  7         14  
  7         27031  
21              
22             sub new {
23 53     53 1 367 my ($class, %args) = @_;
24 53         277 my $self = bless {
25             main_obj => undef,
26             renderer => undef,
27             }, $class;
28 53         313 $self->set_param(\%args, "main_obj", undef, 1);
29 53         214 $self->set_param(\%args, "script_name", undef, 1);
30 53         281 my $sharedir = $self->{main_obj}->get_config('sharedir_path');
31 53         271 $sharedir =~ s{/+$}{};
32             $self->{renderer} = Text::Xslate->new(
33             path => [ File::Spec->catdir($sharedir, 'www', 'templates') ],
34             cache_dir => File::Spec->tmpdir,
35             syntax => 'Kolon',
36             function => $self->template_functions(),
37             warn_handler => sub {
38 0     0   0 bblog("warn", @_);
39             },
40             ## we don't use die_handler because (1) it is called for every
41             ## death even when it's in eval() scope, (2) exceptions are
42             ## caught by Xslate and passed to warn_handler anyway.
43 53         1855 );
44 53         16322 return $self;
45             }
46              
47             sub response_notfound {
48 15     15 1 391 my ($self, $message) = @_;
49 15   100     67 $message ||= 'Not Found';
50 15         187 return ['404',
51             ['Content-Type' => 'text/plain',
52             'Content-Length' => length($message)],
53             [$message]];
54             }
55              
56             sub response_error_html {
57 9     9 1 3345 my ($self, $http_code, $message) = @_;
58 9         55 return $self->_response_template(
59             template => 'error.tx', args => {error => $message},
60             code => $http_code
61             );
62             }
63              
64              
65             sub response_json {
66 147     147 1 293 my ($self, $res_code, $response_object) = @_;
67             my $message = try {
68 147 50   147   4143 die "response must not be undef" if not defined $response_object;
69 147 100 100     1525 if($res_code eq '200' && ref($response_object) eq "HASH" && !exists($response_object->{error})) {
      100        
70 74         218 $response_object->{error} = undef;
71             }
72 147         814 to_json($response_object, {ascii => 1})
73             }catch {
74             undef
75 147     1   1073 };
  1         32  
76 147 100       7300 if(defined($message)) {
77             return [
78 146         1118 $res_code, ['Content-Type' => 'application/json; charset=utf-8'],
79             [$message]
80             ];
81             }else {
82 1         5 return $self->response_json(500, {error => "error while encoding to JSON."});
83             }
84             }
85              
86             sub _response_template {
87 36     36   141 my ($self, %args) = @_;
88 36         100 my $template_name = delete $args{template};
89 36 50       119 croak 'template parameter is mandatory' if not defined $template_name;
90 36         72 my $args = delete $args{args};
91 36   100     191 my $code = delete $args{code} || 200;
92 36   50     185 my $headers = delete $args{headers} || [];
93 36 50       187 if(!Plack::Util::header_exists($headers, 'Content-Type')) {
94 36         973 push(@$headers, 'Content-Type', 'text/html; charset=utf8');
95             }
96 36         2229 my $ret = Encode::encode('utf8', $self->{renderer}->render($template_name, $args));
97 36         4374 return [$code, $headers, [$ret]];
98             }
99              
100              
101             my $REGEXP_URL_CHAR = qr{[A-Za-z0-9~\/._!\?\&=\-%#\+:\;,\@\']};
102             my $REGEXP_HTTP_URL = qr{https?:\/\/$REGEXP_URL_CHAR+};
103             my $REGEXP_ABSOLUTE_PATH = qr{/$REGEXP_URL_CHAR*};
104             my %URL_ATTRIBUTES = map { $_ => 1 } qw(src href);
105              
106             sub _is_valid_link_url {
107 107     107   135 my ($url) = @_;
108 107 100       219 $url = "" if not defined $url;
109 107         5137 return ($url =~ /^(?:$REGEXP_HTTP_URL|$REGEXP_ABSOLUTE_PATH)$/);
110             }
111              
112             sub _html_attributes_string {
113 108     108   244 my ($mandatory_attrs_ref, @attr) = @_;
114 108         303 for(my $i = 0 ; $i < $#attr ; $i += 2) {
115 224         607 $attr[$i] = lc($attr[$i]);
116             }
117 108         754 tie(my %attr, 'Tie::IxHash', @attr);
118 108         3570 foreach my $attr_key (@$mandatory_attrs_ref) {
119 108 100       417 croak "$attr_key attribute is mandatory" if not defined $attr{$attr_key};
120             }
121 55         516 my @attr_strings = ();
122 55         174 foreach my $attr_key (keys %attr) {
123 73         962 my $attr_value = $attr{$attr_key};
124 73         458 my $value_str;
125 73 100       215 if($URL_ATTRIBUTES{$attr_key}) {
126 55 100       97 croak "$attr_key attribute is invalid as a URL" if not _is_valid_link_url($attr_value);
127 24         33 $value_str = $attr_value;
128             }else {
129 18         47 $value_str = html_escape($attr_value);
130             }
131 42         302 push(@attr_strings, html_escape($attr_key) . qq{="$value_str"});
132             }
133 24         140 return join(" ", @attr_strings);
134             }
135              
136             sub _html_link {
137 73     73   4206 my ($text, @attr) = @_;
138 73 100       174 $text = "" if not defined $text;
139 73         248 my $escaped_text = html_escape($text);
140             return try {
141 73     73   2162 my $attr_str = _html_attributes_string(['href'], @attr);
142 19         100 return qq{$escaped_text};
143             }catch {
144 54     54   5849 return $escaped_text;
145 73         496 };
146             }
147              
148             sub _html_link_status_text {
149 13     13   19 my ($text, $url) = @_;
150 13         21 return _html_link($text, href => $url, target => "_blank");
151             }
152              
153             sub _make_path {
154 488     488   498 my ($script_name, $given_path) = @_;
155 488 100       973 if(substr($given_path, 0, 1) eq "/") {
156 486         2841 return "$script_name$given_path";
157             }else {
158 2         11 return $given_path;
159             }
160             }
161              
162             sub template_functions {
163 54     54 1 230 my $script_name = $_[0]->{script_name};
164             return {
165             js => \&JavaScript::Value::Escape::js,
166             link => html_builder(\&_html_link),
167             image => html_builder {
168 35     35   14697 my (@attr) = @_;
169             return try {
170 35         1422 my $attr_str = _html_attributes_string(['src'], @attr);
171 5         18 return qq{}
172             }catch {
173 30         4554 return "";
174 35         380 };
175             },
176             bb_level => sub {
177 54     54   21342 my $level = shift;
178 54 100       163 $level = 0 if not defined $level;
179 54         257 return $level;
180             },
181 322     322   28332 path => sub { _make_path($script_name, $_[0]) },
182 55     55   1624 script_name => sub { $script_name },
183 54         409 };
184             }
185              
186             sub _render_text_segment {
187 24     24   37 my ($self, $timeline_name, $segment, $status) = @_;
188 24 100 66     106 return undef if !defined($segment->{entity}) || !defined($segment->{type});
189 10         43 my $url_builder = $self->{main_obj}->get_timeline_config($timeline_name, "$segment->{type}_entity_url_builder");
190 10         34 my $text_builder = $self->{main_obj}->get_timeline_config($timeline_name, "$segment->{type}_entity_text_builder");
191 10 50 33     42 return undef if !defined($url_builder) || !defined($text_builder);
192 10         23 my $url_str = $url_builder->($segment->{text}, $segment->{entity}, $status);
193 10 50       22 return undef if !_is_valid_link_url($url_str);
194 10         31 my $text_str = $text_builder->($segment->{text}, $segment->{entity}, $status);
195 10 50       26 $text_str = "" if not defined $text_str;
196 10         19 return _html_link_status_text($text_str, $url_str);
197             }
198              
199             sub template_functions_for_timeline {
200 27     27 1 50 my ($self, $timeline_name) = @_;
201 27         94 weaken $self; ## in case the functions are kept by $self
202             return {
203             bb_timestamp => sub {
204 28     28   1709 my ($timestamp_string) = @_;
205 28 100       144 return "" if !$timestamp_string;
206 12         81 my $timezone = $self->_get_timezone($self->{main_obj}->get_timeline_config($timeline_name, "time_zone"));
207 12         3122 my $dt = BusyBird::DateTime::Format->parse_datetime($timestamp_string);
208 12 50       14483 return "" if !defined($dt);
209 12         59 $dt->set_time_zone($timezone);
210 12         234 $dt->set_locale($self->{main_obj}->get_timeline_config($timeline_name, "time_locale"));
211 12         1549 return $dt->strftime($self->{main_obj}->get_timeline_config($timeline_name, "time_format"));
212             },
213             bb_status_permalink => sub {
214 31     31   2150 my ($status) = @_;
215 31         129 my $builder = $self->{main_obj}->get_timeline_config($timeline_name, "status_permalink_builder");
216             my $url = try {
217 31         1014 $builder->($status);
218             }catch {
219 0         0 my ($e) = @_;
220 0         0 bblog("error", "Error in status_permalink_builder: $e");
221 0         0 undef;
222 31         260 };
223 31 100       483 return (_is_valid_link_url($url) ? $url : "");
224             },
225             bb_text => html_builder {
226 31     31   2461 my $status = shift;
227 31 100       149 return "" if not defined $status->{text};
228 7         31 my $segments_ref = split_with_entities($status->{text}, $status->{entities});
229 7         11 my $result_text = "";
230 7         11 foreach my $segment (@$segments_ref) {
231             my $rendered_text = try {
232 24         714 $self->_render_text_segment($timeline_name, $segment, $status)
233             }catch {
234 0         0 my ($e) = @_;
235 0         0 bblog("error", "Error while rendering text: $e");
236 0         0 undef;
237 24         158 };
238 24 100       461 $result_text .= defined($rendered_text) ? $rendered_text : _escape_and_linkify_status_text($segment->{text});
239             }
240 7         36 return $result_text;
241             },
242             bb_attached_image_urls => sub {
243 31     31   2492 my ($status) = @_;
244 31         128 my $urls_builder = $self->{main_obj}->get_timeline_config($timeline_name, "attached_image_urls_builder");
245             my @image_urls = try {
246 31         972 $urls_builder->($status);
247             }catch {
248 0         0 my ($e) = @_;
249 0         0 bblog("error", "Error in attached_image_urls_builder: $e");
250 0         0 ();
251 31         254 };
252 31         2003 return [grep { _is_valid_link_url($_) } @image_urls ];
  11         18  
253             },
254 27         394 };
255             }
256              
257             {
258             my $timezone_cache = Cache::Memory::Simple->new();
259             my $CACHE_EXPIRATION_TIME = 3600 * 24;
260             my $CACHE_SIZE_LIMIT = 100;
261             sub _get_timezone {
262 12     12   21 my ($self, $timezone_string) = @_;
263 12 50       60 if($timezone_cache->count > $CACHE_SIZE_LIMIT) {
264 0         0 $timezone_cache->purge();
265 0 0       0 if($timezone_cache->count > $CACHE_SIZE_LIMIT) {
266 0         0 $timezone_cache->delete_all();
267             }
268             }
269             return $timezone_cache->get_or_set($timezone_string, sub {
270 3     3   68 return DateTime::TimeZone->new(name => $timezone_string),
271 12         178 }, $CACHE_EXPIRATION_TIME);
272             }
273             }
274              
275             sub _escape_and_linkify_status_text {
276 14     14   20 my ($text) = @_;
277 14         15 my $result_text = "";
278 14         14 my $remaining_index = 0;
279 14         174 while($text =~ m/\G(.*?)($REGEXP_HTTP_URL)/sg) {
280 3         7 my ($other_text, $url) = ($1, $2);
281 3         14 $result_text .= html_escape($other_text);
282 3         8 $result_text .= _html_link_status_text($url, $url);
283 3         58 $remaining_index = pos($text);
284             }
285 14         146 $result_text .= html_escape(substr($text, $remaining_index));
286 14         49 return $result_text;
287             }
288              
289             sub _format_status_html_destructive {
290 25     25   49 my ($self, $status, $timeline_name) = @_;
291 25 50       76 $timeline_name = "" if not defined $timeline_name;
292 25 100 66     110 if(ref($status->{retweeted_status}) eq "HASH" && (!defined($status->{busybird}) || ref($status->{busybird}) eq 'HASH')) {
      66        
293 1         2 my $retweet = $status->{retweeted_status};
294 1         3 $status->{busybird}{retweeted_by_user} = $status->{user};
295 1         2 foreach my $key (qw(text created_at user entities)) {
296 4         9 $status->{$key} = $retweet->{$key};
297             }
298             }
299 25         88 return $self->{renderer}->render(
300             "status.tx",
301             {ss => safed($status),
302 25         135 %{$self->template_functions_for_timeline($timeline_name)}}
303             );
304             }
305              
306             my %RESPONSE_FORMATTER_FOR_TL_GET_STATUSES = (
307             html => sub {
308             my ($self, $timeline_name, $code, $response_object) = @_;
309             if($code == 200) {
310             my $result = "";
311             foreach my $status (@{$response_object->{statuses}}) {
312             $result .= $self->_format_status_html_destructive($status, $timeline_name);
313             }
314             $result = Encode::encode('utf8', $result);
315             return [200, ['Content-Type', 'text/html; charset=utf8'], [$result]];
316             }else {
317             return $self->response_error_html($code, $response_object->{error});
318             }
319             },
320             json => sub {
321             my ($self, $timeline_name, $code, $response_object) = @_;
322             return $self->response_json($code, $response_object);
323             },
324             json_only_statuses => sub {
325             my ($self, $timeline_name, $code, $response_object) = @_;
326             my $res_array = defined($response_object->{error}) ? [] : $response_object->{statuses};
327             my $res_msg = try {
328             to_json($res_array, {ascii => 1});
329             }catch {
330             "[]"
331             };
332             return [
333             $code, ['Content-Type' => 'application/json; charset=utf-8'],
334             [$res_msg]
335             ];
336             },
337             );
338              
339             sub response_statuses {
340 87     87 1 24959 my ($self, %args) = @_;
341 87 50 66     377 if(!defined($args{statuses}) && !defined($args{error})) {
342 0         0 croak "stautses or error parameter is mandatory";
343             }
344 87         222 foreach my $param_key (qw(http_code format)) {
345 174 50       525 croak "$param_key parameter is mandatory" if not defined($args{$param_key});
346             }
347 87         312 my $formatter = $RESPONSE_FORMATTER_FOR_TL_GET_STATUSES{lc($args{format})};
348 87 100       229 if(!defined($formatter)) {
349 1         2 $formatter = $RESPONSE_FORMATTER_FOR_TL_GET_STATUSES{html};
350 1         2 delete $args{statuses};
351 1         4 $args{error} = "Unknown format: $args{format}";
352 1         2 $args{http_code} = 400;
353             }
354 87 100       758 return $formatter->($self, $args{timeline_name}, $args{http_code},
355             defined($args{error}) ? {error => $args{error}}
356             : {error => undef, statuses => $args{statuses}});
357             }
358              
359             my %TIMELINE_CONFIG_FILTER_FOR = (
360             timeline_web_notifications => sub { defined($_[0]) ? "$_[0]" : ""},
361             acked_statuses_load_count => sub { $_[0] =~ /^\d+$/ ? $_[0] : undef },
362             default_level_threshold => sub { $_[0] =~ /^\d+$/ ? $_[0] : undef},
363             );
364              
365             sub _create_timeline_config_json {
366 13     13   24 my ($self, $timeline_name) = @_;
367 13         27 my %config = ();
368 13         48 foreach my $key (keys %TIMELINE_CONFIG_FILTER_FOR) {
369 39         57 my $config_filter = $TIMELINE_CONFIG_FILTER_FOR{$key};
370 39         104 my $orig_value = $self->{main_obj}->get_timeline_config($timeline_name, $key);
371 39         97 $config{$key} = $config_filter->($orig_value);
372             }
373 13         75 return to_json(\%config);
374             }
375              
376             sub response_timeline {
377 21     21 1 767 my ($self, $timeline_name) = @_;
378 21         124 my $timeline = $self->{main_obj}->get_timeline($timeline_name);
379 21 100       323 return $self->response_notfound("Cannot find $timeline_name") if not defined($timeline);
380            
381 13         58 return $self->_response_template(
382             template => "timeline.tx",
383             args => {
384             timeline_name => $timeline_name,
385             timeline_config_json => $self->_create_timeline_config_json($timeline_name),
386             post_button_url => $self->{main_obj}->get_timeline_config($timeline_name, "post_button_url"),
387             attached_image_max_height => $self->{main_obj}->get_timeline_config($timeline_name, "attached_image_max_height"),
388             attached_image_show_default_bool =>
389             ($self->{main_obj}->get_timeline_config($timeline_name, "attached_image_show_default") eq "visible")
390             }
391             );
392             }
393              
394             sub response_timeline_list {
395 14     14 1 314451 my ($self, %args) = @_;
396 14         52 foreach my $key (qw(timeline_unacked_counts total_page_num cur_page)) {
397 42 50       169 croak "$key parameter is mandatory" if not defined $args{$key};
398             }
399 14 50       72 croak "timeline_unacked_counts must be an array-ref" if ref($args{timeline_unacked_counts}) ne "ARRAY";
400            
401 14         111 my %input_args = (last_page => $args{total_page_num} - 1);
402 14         30 foreach my $input_key (qw(cur_page)) {
403 14         49 $input_args{$input_key} = $args{$input_key};
404             }
405            
406 35         639 $input_args{timeline_unacked_counts_json} = [map {
407 14         38 +{name => $_->{name}, counts_json => to_json($_->{counts})}
408 14         28 } @{$args{timeline_unacked_counts}}];
409            
410 14         390 my $pager_entry_max = $self->{main_obj}->get_config('timeline_list_pager_entry_max');
411 14         59 my $left_margin = int($pager_entry_max / 2);
412 14         30 my $right_margin = $pager_entry_max - $left_margin;
413 14 100       106 $input_args{page_list} =
    100          
    100          
414             $args{total_page_num} <= $pager_entry_max ? [0 .. ($args{total_page_num} - 1)]
415             : $args{cur_page} <= $left_margin ? [0 .. ($pager_entry_max - 1)]
416             : $args{cur_page} >= ($args{total_page_num} - $right_margin)
417             ? [($args{total_page_num} - $pager_entry_max) .. ($args{total_page_num} - 1)]
418             : [($args{cur_page} - $left_margin) .. ($args{cur_page} + $right_margin - 1)];
419             $input_args{page_path} = sub {
420 166     166   216 my ($page) = @_;
421 166         326 return _make_path($self->{script_name}, "/?page=$page");
422 14         95 };
423 14         71 return $self->_response_template(
424             template => "timeline_list.tx",
425             args => \%input_args,
426             );
427             }
428              
429             1;
430              
431             __END__