logbot-web 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/perl
  2. # mojolicious web handler
  3. # must set env var LOGBOT_CONFIG to config filename
  4. #
  5. # in production runs as a daemon behind a reverse proxy, which handles requests
  6. # for static assets (web/public/static)
  7. #
  8. # use `dev-make` to build static assets
  9. use local::lib;
  10. use v5.10;
  11. use strict;
  12. use warnings;
  13. use FindBin qw( $RealBin );
  14. use lib "$RealBin/lib";
  15. BEGIN { $ENV{TZ} = 'UTC' }
  16. use DateTime ();
  17. use IO::Compress::Gzip qw( gzip );
  18. use LogBot::Config qw( find_config load_all_configs load_config reload_config );
  19. use LogBot::MemCache ();
  20. use LogBot::Util qw( file_for file_time time_to_ymd );
  21. use LogBot::Web::Util qw( channel_from_param channel_topics linkify render_init rewrite_old_urls );
  22. use Mojo::ByteStream ();
  23. use Mojo::Log ();
  24. use Mojo::Util qw( dumper );
  25. use Mojolicious::Lite qw( app );
  26. # load networks
  27. my $networks = [];
  28. {
  29. if (my $config = $ENV{LOGBOT_CONFIG}) {
  30. # load specific networks (mostly for dev)
  31. foreach my $network (split(/,/, $config)) {
  32. push @{$networks}, load_config(find_config($network), web => 1);
  33. }
  34. } else {
  35. # load all networks
  36. foreach my $network (values %{ load_all_configs(web => 1) }) {
  37. push @{$networks}, $network;
  38. }
  39. }
  40. $networks = [sort { $a->{name} cmp $b->{name} } @{$networks}];
  41. }
  42. # configure mojo
  43. my $is_production = app->mode() eq 'production';
  44. app->secrets('!logbot!');
  45. app->renderer->paths([$RealBin . '/web/templates']);
  46. app->static->paths([$RealBin . '/web/public']);
  47. app->config(
  48. hypnotoad => {
  49. listen => ['http://127.0.0.1:' . ($ENV{LOGBOT_PORT} // 3001)],
  50. pid_file => ($ENV{LOGBOT_PID_FILE} // ($RealBin . '/logbot-web.pid')),
  51. },
  52. );
  53. app->log(Mojo::Log->new(path => $is_production ? '/var/log/logbot/mojo.log' : 'log/mojo.log'));
  54. plugin AccessLog => {
  55. log => ($is_production ? '/var/log/logbot/access.log' : 'log/access.log'),
  56. format => '%h %{X-Network}o - %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"',
  57. };
  58. plugin 'Status' => {
  59. route => app->routes->under(
  60. '_status' => sub {
  61. my ($c) = @_;
  62. my $password = $ENV{'LOGBOT_STATUS_PASSWORD'} // 'status';
  63. return 1 if $password eq '';
  64. return 1 if ($c->req->url->to_abs->userinfo // '') eq 'logbot:' . $password;
  65. $c->res->headers->www_authenticate('Basic');
  66. $c->render(text => 'Authentication required', status => 401);
  67. return undef;
  68. }
  69. )
  70. };
  71. my $memcache = LogBot::MemCache->new(binary => $is_production);
  72. render_init($RealBin);
  73. # per-request initialisation
  74. under sub {
  75. my ($c) = @_;
  76. # determine current config
  77. my $config_index = 0;
  78. if (scalar(@{$networks}) > 1) {
  79. my $host = lc($c->req->url->to_abs->host);
  80. my $dot_posn = index($host, '.');
  81. if ($dot_posn != -1) {
  82. $host = substr($host, 0, $dot_posn);
  83. for (my $i = 0; $i < scalar(@{$networks}); $i++) {
  84. next unless $networks->[$i]->{name} eq $host;
  85. $config_index = $i;
  86. last;
  87. }
  88. }
  89. }
  90. # reload config
  91. $networks->[$config_index] = reload_config($networks->[$config_index]);
  92. my $config = $networks->[$config_index];
  93. # store network name in response header for logging
  94. $c->res->headers->add('X-Network' => $config->{name});
  95. # for client-side list cache
  96. my $topics_lastmod = file_time(file_for($config, 'topics_lastmod')) // 0;
  97. $c->stash(
  98. config => $config,
  99. networks => $networks,
  100. network => $config->{name},
  101. channels => $config->{_derived}->{visible_channels},
  102. topics => channel_topics($config),
  103. channel => '',
  104. date => '',
  105. error => '',
  106. event_count => 0,
  107. bot_event_count => 0,
  108. page => '',
  109. today => DateTime->now()->truncate(to => 'day'),
  110. is_today => 0,
  111. cache_prefix => $config->{_derived}->{time} . '.' . $config->{name} . '.',
  112. channel_list_id => $config->{_derived}->{time} . '.' . $topics_lastmod,
  113. topics_lastmod => $topics_lastmod,
  114. );
  115. return 1;
  116. };
  117. #
  118. # default => about logbot || search
  119. get '/' => sub {
  120. my ($c) = @_;
  121. # redirect old urls
  122. if (my $url = rewrite_old_urls($c)) {
  123. # url was formed server-side, no need to bounce through js redirect
  124. if ($url !~ /#/) {
  125. return $c->redirect_to($url);
  126. }
  127. $c->stash(redirect_to => $url);
  128. return $c->render('redirect');
  129. }
  130. # search
  131. my $q = $c->req->query_params->param('q');
  132. if (defined($q)) {
  133. LogBot::Web::Search::render($c, $q);
  134. } else {
  135. # index
  136. LogBot::Web::Index::render($c);
  137. }
  138. };
  139. # config
  140. get '/_config' => sub {
  141. my ($c) = @_;
  142. LogBot::Web::Config::render($c);
  143. };
  144. # channel list
  145. get '/_channels' => sub {
  146. my ($c) = @_;
  147. LogBot::Web::List::render($c);
  148. };
  149. get '/_channels_body' => sub {
  150. my ($c) = @_;
  151. LogBot::Web::List::render($c, { body_only => 1 });
  152. };
  153. # network stats
  154. get '/_stats' => sub {
  155. my ($c) = @_;
  156. LogBot::Web::Stats::render($c);
  157. };
  158. get '/_stats/meta' => sub {
  159. my ($c) = @_;
  160. LogBot::Web::Stats::render_meta($c);
  161. };
  162. get '/_stats/hours' => sub {
  163. my ($c) = @_;
  164. LogBot::Web::Stats::render_hours($c);
  165. };
  166. # debugging
  167. if (!$is_production) {
  168. get '/_stash' => sub {
  169. my ($c) = @_;
  170. $c->stash(today => $c->stash('today')->ymd());
  171. $c->render(text => dumper($c->stash), format => 'txt');
  172. };
  173. }
  174. # robots.txt
  175. my $robots_txt = <<'EOF';
  176. # http://law.di.unimi.it/BUbiNG.html
  177. # 20% of my traffic was from this bot
  178. User-agent: BUbiNG
  179. Disallow: /
  180. # Marketing/SEO bot
  181. User-agent: SemrushBot
  182. Disallow: /
  183. User-agent: SemrushBot-SA
  184. Disallow: /
  185. EOF
  186. get '/robots.txt' => sub {
  187. my ($c) = @_;
  188. $c->render(text => $robots_txt, format => 'txt');
  189. };
  190. # /channel => redirect to current date
  191. get '/#channel' => sub {
  192. my ($c) = @_;
  193. my $channel = channel_from_param($c) // return;
  194. # redirect to current date
  195. my $path = $c->req->url->path;
  196. $path .= '/' unless substr($path, -1) eq '/';
  197. $c->redirect_to($path . time_to_ymd(time()));
  198. };
  199. # /channel/date => show logs
  200. get '/#channel/:date' => [date => qr/\d{8}/] => sub {
  201. my ($c) = @_;
  202. LogBot::Web::Channel::render_logs($c);
  203. };
  204. get '/#channel/:date/raw' => [date => qr/\d{8}/] => sub {
  205. my ($c) = @_;
  206. LogBot::Web::Channel::render_raw($c);
  207. };
  208. get '/#channel/link/:time/:nick' => [time => qr /\d+/] => sub {
  209. my ($c) = @_;
  210. LogBot::Web::Channel::redirect_to($c);
  211. };
  212. get '/#channel/stats' => sub {
  213. my ($c) = @_;
  214. LogBot::Web::Stats::render($c, require_channel => 1);
  215. };
  216. get '/#channel/stats/meta' => sub {
  217. my ($c) = @_;
  218. LogBot::Web::Stats::render_meta($c, require_channel => 1);
  219. };
  220. get '/#channel/stats/hours' => sub {
  221. my ($c) = @_;
  222. LogBot::Web::Stats::render_hours($c);
  223. };
  224. get '/#channel/stats/nicks' => sub {
  225. my ($c) = @_;
  226. LogBot::Web::Stats::render_nicks($c);
  227. };
  228. # 404 handler
  229. any '*' => sub {
  230. my ($c) = @_;
  231. $c->res->code(404);
  232. $c->res->message('Not Found');
  233. LogBot::Web::Index::render($c, { error => 'Not Found.' });
  234. };
  235. my %cache;
  236. # static file with timestamp
  237. helper static => sub {
  238. my ($c, $file) = @_;
  239. return $cache{static}->{$file} //= '/static/' . $file . '?' . file_time($RealBin . '/web/public/static/' . $file);
  240. };
  241. # inline svg
  242. helper svg => sub {
  243. my ($c, $file) = @_;
  244. return $cache{svg}->{$file} //= Mojo::ByteStream->new(slurp($RealBin . '/web/svg/' . $file . '.svg'));
  245. };
  246. # linkify text
  247. helper linkify => sub {
  248. my ($c, $text) = @_;
  249. return linkify($text);
  250. };
  251. # cache
  252. helper cached => sub {
  253. my ($c, $key, $callback) = @_;
  254. return $key eq ''
  255. ? $callback->()
  256. : Mojo::ByteStream->new($memcache->cached($c->stash('cache_prefix') . $key, $callback));
  257. };
  258. hook after_render => sub {
  259. my ($c, $output, $format) = @_;
  260. my $headers = $c->res->headers;
  261. # CSP
  262. state $csp = join(
  263. '; ',
  264. q{default-src 'self'},
  265. q{object-src 'none'},
  266. q{frame-ancestors 'none'},
  267. q{base-uri 'none'},
  268. q{style-src 'self' 'unsafe-inline'}, # unsafe-inline for chosen, top-nick graph
  269. q{img-src 'self' data:}, # data: for pikaday
  270. );
  271. $headers->header('Content-Security-Policy' => $csp);
  272. # preload fonts
  273. state $link = join(', ',
  274. map { '<' . $c->url_for($_)->to_abs . '>; rel=preload; as=font' }
  275. qw( /static/hind-regular.ttf /static/hind-medium.ttf /static/hind-bold.ttf ));
  276. $headers->header(Link => $link);
  277. # no need to expose this info
  278. $headers->remove('Server');
  279. # gzip compression
  280. if (($c->req->headers->accept_encoding // '') =~ /gzip/i) {
  281. $headers->append(Vary => 'Accept-Encoding');
  282. $headers->content_encoding('gzip');
  283. gzip($output, \my $compressed);
  284. ${$output} = $compressed;
  285. }
  286. };
  287. app->start;