Base.pm 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. package Trog::Renderer::Base;
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Encode qw{encode_utf8};
  6. use Hash::Util ();
  7. use IO::Compress::Gzip;
  8. use Text::Xslate;
  9. use Trog::Component ();
  10. use Trog::Themes;
  11. use Trog::Config;
  12. use Time::HiRes qw{tv_interval};
  13. =head1 Trog::Renderer::Base
  14. Basic rendering structure, subclass me.
  15. Sets up the functions which must be present for all templates: render_it, for rendering dynamic template strings coming from a post, and component, for pulling in a Trog::Component by name.
  16. =cut
  17. our %renderers;
  18. =head2 render(%options)
  19. Render a template and build the PSGI response around it.
  20. Recognized options:
  21. template => the template's filename, relative to the template dir. Required.
  22. contenttype => what we're rendering, e.g. 'text/html'. Picks the template dir.
  23. component => render a component rather than a whole page. Returns just the
  24. body string, with no headers and no compression.
  25. data => the variables handed to the template.
  26. code => HTTP status for the response.
  27. headers => extra headers, merged over the computed ones.
  28. deflate => gzip the body and set Content-Encoding.
  29. post_processor => CODEREF run over the body before headers are computed, for
  30. things like minifiers.
  31. child_processor / child_renderer => override how post bodies are rendered by
  32. the render_it() template function. Built for you if omitted.
  33. Dies unless Xslate can resolve and render the template. It also dies when the
  34. template reads a variable that data does not have, so a variable that a
  35. template may read must be in data, as undef if it has no value. Returns the
  36. body string for components, and a PSGI arrayref of [ code, headers, body ]
  37. otherwise.
  38. =cut
  39. sub render (%options) {
  40. die "Templated renders require a template to be passed" unless $options{template};
  41. # Xslate resolves includes against every dir in path, first match winning,
  42. # so handing it both lets a theme override individual templates without
  43. # having to fork the ones next to them. With no theme configured this is
  44. # just the stock dir, exactly as it was.
  45. my @template_dirs = Trog::Themes::template_dirs( $options{contenttype}, $options{component} );
  46. #TODO make this work with posts all the time
  47. $options{child_processor} //= Text::Xslate->new( path => \@template_dirs );
  48. my $child_processor = $options{child_processor};
  49. $options{child_renderer} //= sub {
  50. my ( $template_string, $options ) = @_;
  51. # If it fails to render, it must be something else
  52. my $out = eval { $child_processor->render_string( $template_string, $options ) };
  53. return $out ? $out : $template_string;
  54. };
  55. # Keyed on the whole search path, not just the winning dir: two different
  56. # paths that happen to start with the same dir are not the same renderer.
  57. my $renderer_key = join( ':', @template_dirs );
  58. $renderers{$renderer_key} //= Text::Xslate->new(
  59. path => \@template_dirs,
  60. function => {
  61. render_it => $options{child_renderer},
  62. # A plain package sub rather than a closure, so the \%renderers
  63. # memoization here cannot bake in whatever the first caller for this
  64. # path happened to pass -- which it can, and does, for render_it.
  65. component => \&Trog::Component::component,
  66. },
  67. );
  68. my $code = $options{code};
  69. # Xslate resolves the template itself, against the same @template_dirs it
  70. # will actually use. There used to be a pre-flight test here, and it was
  71. # wrong twice over: it resolved through template_dir(), which returns one
  72. # winning directory rather than the search path, so it could pass on a file
  73. # Xslate would not use and name a nonexistent stock path in its error while
  74. # a good theme template existed. And the condition itself, -f $t || -s $t,
  75. # short-circuited on -f, so an empty file passed, while a *directory* passed
  76. # on -s returning its size.
  77. #
  78. # $Trog::Component::error is how a failed component() call reaches us at all:
  79. # Xslate catches an exception thrown out of a template function, warns, and
  80. # renders on with an empty string in its place, so the render below succeeds
  81. # with a hole in it. Localised here so that a nested render (a component
  82. # rendering its own template) doesn't answer for its parent's failure.
  83. # Sized first, because perl checks the keys of a locked hash only once the
  84. # hash has buckets, and an empty one has none.
  85. my %vars;
  86. keys(%vars) = 1;
  87. %vars = %{ $options{data} // {} };
  88. Hash::Util::lock_keys(%vars);
  89. my $body = eval {
  90. local $Trog::Component::error;
  91. my $out = encode_utf8( $renderers{$renderer_key}->render( $options{template}, \%vars ) );
  92. die $Trog::Component::error if $Trog::Component::error;
  93. $out;
  94. };
  95. die "Could not render template '$options{template}' (searched @template_dirs): $@" unless defined $body;
  96. # Users can supply a post_processor to futz with the output (such as with minifiers) if they wish.
  97. $body = $options{post_processor}->($body) if $options{post_processor} && ref $options{post_processor} eq 'CODE';
  98. # Users can supply custom headers as part of the data in options.
  99. my %headers = headers( \%options, $body );
  100. return $body if $options{component};
  101. return [ $code, [%headers], [$body] ] unless $options{deflate};
  102. $headers{"Content-Encoding"} = "gzip";
  103. my $dfh;
  104. IO::Compress::Gzip::gzip( \$body => \$dfh );
  105. print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
  106. $headers{"Content-Length"} = length($dfh);
  107. return [ $code, [%headers], [$dfh] ];
  108. }
  109. =head2 headers($options, $body) = %headers
  110. The response headers for a rendered body: content type and length, caching,
  111. Server-Timing, and the security headers (CSP, X-Frame-Options, HSTS when we're
  112. on https, nosniff). Anything in $options->{headers} is merged over the top.
  113. ETags are only set for logged in users, as everyone else is served out of the
  114. static render cache.
  115. =cut
  116. sub headers ( $options, $body ) {
  117. my $query = $options->{data};
  118. my $uh = ref $options->{headers} eq 'HASH' ? $options->{headers} : {};
  119. my $ct = $options->{contenttype} eq 'text/html' ? "text/html; charset=UTF-8" : "$options->{contenttype};";
  120. my %headers = (
  121. 'Content-Type' => $ct,
  122. 'Content-Length' => length($body),
  123. 'Cache-Control' => $query->{cachecontrol} // $Trog::Vars::cache_control{revalidate},
  124. 'X-Content-Type-Options' => 'nosniff',
  125. 'Vary' => 'Accept-Encoding',
  126. 'Server-Timing' => "render;dur=" . ( tv_interval( $query->{start} ) * 1000 ),
  127. %$uh,
  128. );
  129. #Disallow framing UNLESS we are in embed mode
  130. my $ancestor = $query->{domain} || 'none';
  131. $headers{"Content-Security-Policy"} = qq{frame-ancestors $ancestor} unless $query->{embed};
  132. $headers{'X-Frame-Options'} = 'DENY' unless $query->{embed};
  133. $headers{'Referrer-Policy'} = 'no-referrer-when-downgrade';
  134. #CSP. Yet another layer of 'no mixed content' plus whitelisted execution of remote resources.
  135. # Component renders (posts.tx, the bars) get a data hash built by the route
  136. # rather than the request, and it carries no scheme.
  137. my $scheme = ( $query->{scheme} // '' ) eq 'https' ? "$query->{scheme}:" : '';
  138. my $conf = Trog::Config::get();
  139. my $sites = $conf->param('security.allow_embeds_from') // '';
  140. $headers{'Content-Security-Policy'} .= ";default-src $scheme 'self' data: 'unsafe-eval' 'unsafe-inline' $sites";
  141. $headers{'Content-Security-Policy'} .= ";object-src 'none'";
  142. # Force https if we are https
  143. $headers{'Strict-Transport-Security'} = 'max-age=63072000' if ( $query->{scheme} // '' ) eq 'https';
  144. # We only set etags when users are logged in, cause we don't use statics
  145. $headers{'ETag'} = $query->{etag} if $query->{etag} && $query->{user};
  146. return %headers;
  147. }
  148. 1;