File Coverage

blib/lib/BusyBird/Main/PSGI/View.pm
Criterion Covered Total %
statement 235 249 94.3
branch 55 70 78.5
condition 20 27 74.0
subroutine 52 53 98.1
pod 9 9 100.0
total 371 408 90.9


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