package FixMyStreet::Template;
use parent Template;
use strict;
use warnings;
use FixMyStreet;
use mySociety::Locale;
use Attribute::Handlers;
use HTML::Scrubber;
use HTML::TreeBuilder;
use FixMyStreet::Template::SafeString;
use FixMyStreet::Template::Context;
use FixMyStreet::Template::Stash;
use RABX;
use IO::String;
my %FILTERS;
my %SUBS;
# HASH is where we want to store the thing, either FILTERS or SUBS. SYMBOL is a
# symbol table ref, where NAME then returns its name (perlref has the gory
# details). FN is a ref to the function. DATA is an arrayref of any passed in
# arguments.
sub add_attr {
my ($hash, $symbol, $fn, $data) = @_;
my $name = $data ? $data->[0] : *{$symbol}{NAME};
$hash->{$name} = $fn;
}
# Create two attributes, Filter and Fn, which you apply to a function to turn
# them into a template filter or function. You can optionally provide an argument
# name for what to call the thing in the template if it's not the same as the
# function name. They're called at the BEGIN stage rather than the default CHECK
# as this code might be imported by an eval.
sub Filter : ATTR(CODE,BEGIN) {
add_attr(\%FILTERS, $_[1], $_[2], $_[4]);
}
sub Fn : ATTR(CODE,BEGIN) {
add_attr(\%SUBS, $_[1], $_[2], $_[4]);
}
sub new {
my ($class, $config) = @_;
my $disable_autoescape = delete $config->{disable_autoescape};
$config->{FILTERS}->{$_} = $FILTERS{$_} foreach keys %FILTERS;
$config->{ENCODING} = 'utf8';
if (!$disable_autoescape) {
$config->{STASH} = FixMyStreet::Template::Stash->new($config);
$config->{CONTEXT} = FixMyStreet::Template::Context->new($config);
}
$class->SUPER::new($config);
}
sub process {
my ($class, $template, $vars, $output, %options) = @_;
$vars->{$_} = $SUBS{$_} foreach keys %SUBS;
$class->SUPER::process($template, $vars, $output, %options);
}
=head2 loc
[% loc('Some text to localize', 'Optional comment for translator') %]
Passes the text to the localisation engine for translations.
Pass in "JS" as the optional comment to escape single quotes (for use in JavaScript).
=cut
sub loc : Fn {
my $s = _(@_);
$s =~ s/'/\\'/g if $_[1] && $_[1] eq 'JS';
return FixMyStreet::Template::SafeString->new($s);
}
=head2 nget
[% nget( 'singular', 'plural', $number ) %]
Use first or second string depending on the number.
=cut
sub nget : Fn {
return FixMyStreet::Template::SafeString->new(mySociety::Locale::nget(@_));
}
=head2 file_exists
[% file_exists("web/cobrands/$cobrand/image.png") %]
Checks to see if a file exists, relative to the codebase root.
=cut
sub file_exists : Fn {
-e FixMyStreet->path_to(@_);
}
=head2 html_filter
Same as Template Toolkit's html_filter, but escapes ' too, as we don't (and
shouldn't have to) know whether we'll be used inbetween single or double
quotes.
=cut
sub html_filter : Filter('html') {
my $text = shift;
for ($text) {
s/&/&/g;
s/</g;
s/>/>/g;
s/"/"/g;
s/'/'/g;
}
return $text;
}
sub conditional_escape {
my $text = shift;
$text = html_filter($text) unless UNIVERSAL::isa($text, 'FixMyStreet::Template::SafeString');
return $text;
}
=head2 html_paragraph
Same as Template Toolkit's html_paragraph, but converts single newlines
into
s too.
=cut
sub html_paragraph : Filter('html_para') {
my $text = shift;
$text = conditional_escape($text);
my @paras = grep { $_ } split(/(?:\r?\n){2,}/, $text);
s/\r?\n/
\n/g for @paras;
$text = "
\n" . join("\n
\n\n\n", @paras) . "
\n"; return FixMyStreet::Template::SafeString->new($text); } sub sanitize { my $text = shift; $text = $$text if UNIVERSAL::isa($text, 'FixMyStreet::Template::SafeString'); my %allowed_tags = map { $_ => 1 } qw( p ul ol li br b i strong em ); my $scrubber = HTML::Scrubber->new( rules => [ %allowed_tags, a => { href => qr{^(http|/|tel)}i, style => 1, target => qr/^_blank$/, title => 1, class => qr/^js-/ }, img => { src => 1, alt => 1, width => 1, height => 1, hspace => 1, vspace => 1, align => 1, sizes => 1, srcset => 1 }, font => { color => 1 }, span => { style => 1 }, ] ); $text = $scrubber->scrub($text); return $text; } =head2 email_sanitize_text Intended for use in the _email_comment_list.txt template to allow HTML in updates from staff/superusers. Sanitizes the HTML and then converts it all to text. =cut sub email_sanitize_text : Fn('email_sanitize_text') { my $update = shift; my $text = $update->{item_text}; my $extra = $update->{item_extra}; $extra = $extra ? RABX::wire_rd(new IO::String($extra)) : {}; my $staff = $extra->{is_superuser} || $extra->{is_body_user}; return $text unless $staff; $text = FixMyStreet::Template::sanitize($text); my $tree = HTML::TreeBuilder->new_from_content($text); _sanitize_elt($tree); return $tree->as_text; } my $list_type; my $list_num; my $sanitize_text_subs = { b => [ '*', '*' ], strong => [ '*', '*' ], i => [ '_', '_' ], em => [ '_', '_' ], p => [ '', "\n\n" ], li => [ '', "\n\n" ], }; sub _sanitize_elt { my $elt = shift; foreach ($elt->content_list) { next unless ref $_; $list_type = $_->tag, $list_num = 1 if $_->tag eq 'ol' || $_->tag eq 'ul'; _sanitize_elt($_); $_->replace_with("\n") if $_->tag eq 'br'; $_->replace_with('[image: ', $_->attr('alt'), ']') if $_->tag eq 'img'; $_->replace_with($_->as_text, ' [', $_->attr('href'), ']') if $_->tag eq 'a'; $_->replace_with_content if $_->tag eq 'span' || $_->tag eq 'font'; $_->replace_with_content if $_->tag eq 'ul' || $_->tag eq 'ol'; if ($_->tag eq 'li') { $sanitize_text_subs->{li}[0] = $list_type eq 'ol' ? "$list_num. " : '* '; $list_num++; } if (my $sub = $sanitize_text_subs->{$_->tag}) { $_->preinsert($sub->[0]); $_->postinsert($sub->[1]); $_->replace_with_content; } } } =head2 email_sanitize_html Intended for use in the _email_comment_list.html template to allow HTML in updates from staff/superusers. =cut sub email_sanitize_html : Fn('email_sanitize_html') { my $update = shift; my $text = $update->{item_text}; my $extra = $update->{item_extra}; $extra = $extra ? RABX::wire_rd(new IO::String($extra)) : {}; my $staff = $extra->{is_superuser} || $extra->{is_body_user}; return _staff_html_markup($text, $staff); } sub _staff_html_markup { my ( $text, $staff ) = @_; unless ($staff) { return html_paragraph(add_links($text)); } $text = sanitize($text); # Apply Markdown-style italics $text =~ s{\*(\S.*?\S)\*}{$1}; # Mark safe so add_links doesn't escape everything. $text = FixMyStreet::Template::SafeString->new($text); $text = add_links($text); # If the update already has block-level elements then don't wrap # individual lines inelements, as we assume the user knows what # they're doing. unless ($text =~ /<(p|ol|ul)>/) { $text = html_paragraph($text); } return $text; } =head2 add_links [% text | add_links | html_para %] Add some links to some text (and thus HTML-escapes the other text). =cut sub add_links { my $text = shift; $text = conditional_escape($text); $text =~ s/\r//g; $text =~ s{(?$1" . _space_slash($2) . ''}ge; return FixMyStreet::Template::SafeString->new($text); } sub _space_slash { my $t = shift; $t =~ s{/(?!$)}{/ }g; return $t; } 1;