| # Copyright (C) all contributors <meta@public-inbox.org> |
| # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt> |
| # |
| # Main web interface for mailing list archives |
| # |
| # We focus on the lowest common denominators here: |
| # - targeted at text-only console browsers (w3m, links, etc..) |
| # - Only basic HTML, CSS only for line-wrapping <pre> text content for GUIs |
| # and diff/syntax-highlighting (optional) |
| # - No JavaScript, graphics or icons allowed. |
| # - Must not rely on static content |
| # - UTF-8 is only for user-content, 7-bit US-ASCII for us |
| package PublicInbox::WWW; |
| use strict; |
| use v5.10.1; |
| use autodie qw(chdir opendir); |
| use PublicInbox::Config; |
| use PublicInbox::Git; |
| use PublicInbox::Hval; |
| use URI::Escape qw(uri_unescape); |
| use PublicInbox::MID qw(mid_escape); |
| use PublicInbox::GitHTTPBackend; |
| use PublicInbox::UserContent; |
| use PublicInbox::WwwStatic qw(r path_info_raw); |
| use PublicInbox::Eml; |
| |
| # TODO: consider a routing tree now that we have more endpoints: |
| our $INBOX_RE = qr!\A/([\w\-][\w\.\-\+]*)!; |
| our $MID_RE = qr!([^/]+)!; |
| our $END_RE = qr!(T/|t/|d/|t\.mbox(?:\.gz)?|t\.atom|raw|)!; |
| our $ATTACH_RE = qr!([0-9][0-9\.]*)-($PublicInbox::Hval::FN)!; |
| our $OID_RE = qr![a-f0-9]{7,}!; |
| |
| sub new { |
| my ($class, $pi_cfg) = @_; |
| bless { pi_cfg => $pi_cfg // PublicInbox::Config->new }, $class; |
| } |
| |
| # backwards compatibility, do not use |
| sub run { |
| my ($req, $method) = @_; |
| PublicInbox::WWW->new->call($req->env); |
| } |
| |
| sub call { |
| my ($self, $env) = @_; |
| my $ctx = { env => $env, www => $self }; |
| |
| # we don't care about multi-value |
| # '0' isn't a QUERY_STRING we care about |
| if (my $qs = $env->{QUERY_STRING}) { |
| utf8::decode($qs); |
| $qs =~ tr/+/ /; |
| %{$ctx->{qp}} = map { |
| # we only use single-char query param keys |
| if (s/\A([A-Za-z])=//) { |
| $1 => uri_unescape($_) |
| } elsif (/\A[a-z]\z/) { # some boolean options |
| $_ => '' |
| } else { |
| () # ignored |
| } |
| } split(/[&;]+/, $qs); |
| } |
| my $path_info = path_info_raw($env); |
| my $method = $env->{REQUEST_METHOD}; |
| |
| if ($method eq 'POST') { |
| if ($path_info =~ m!$INBOX_RE/(?:(?:git/)?([0-9]+)(?:\.git)?/)? |
| (git-upload-pack)\z!x) { |
| my ($epoch, $path) = ($2, $3); |
| return invalid_inbox($ctx, $1) || |
| serve_git($ctx, $epoch, $path); |
| } elsif ($path_info =~ m!$INBOX_RE/(\w+)\.sql\.gz\z!o) { |
| return get_altid_dump($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/$ATTACH_RE\z!o) { |
| my ($idx, $fn) = ($3, $4); |
| return invalid_inbox_mid($ctx, $1, $2) || |
| get_attach($ctx, $idx, $fn); |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/\z!o) { |
| return invalid_inbox_mid($ctx, $1, $2) || mbox_results($ctx); |
| } elsif ($path_info =~ m!$INBOX_RE/\z!o) { |
| return invalid_inbox($ctx, $1) || mbox_results($ctx); |
| } |
| } |
| elsif ($method !~ /\A(?:GET|HEAD)\z/) { |
| return r(405); |
| } |
| |
| # top-level indices and feeds |
| if ($path_info eq '/') { |
| require PublicInbox::WwwListing; |
| PublicInbox::WwwListing->response($ctx); |
| } elsif ($path_info eq '/manifest.js.gz') { |
| require PublicInbox::ManifestJsGz; |
| PublicInbox::ManifestJsGz->response($ctx); |
| } elsif ($path_info =~ m!$INBOX_RE\z!o) { |
| invalid_inbox($ctx, $1) || r301($ctx, $1); |
| } elsif ($path_info =~ m!$INBOX_RE(?:/|/index\.html)?\z!o) { |
| invalid_inbox($ctx, $1) || get_index($ctx); |
| } elsif ($path_info =~ m!$INBOX_RE/(?:atom\.xml|new\.atom)\z!o) { |
| invalid_inbox($ctx, $1) || get_atom($ctx); |
| } elsif ($path_info =~ m!$INBOX_RE/new\.html\z!o) { |
| invalid_inbox($ctx, $1) || get_new($ctx); |
| } elsif ($path_info =~ |
| m!$INBOX_RE/topics_(new|active)\.(atom|html)\z!o) { |
| get_topics($ctx, $1, $2, $3); |
| } elsif ($path_info =~ m!$INBOX_RE/description\z!o) { |
| get_description($ctx, $1); |
| } elsif ($path_info =~ m!$INBOX_RE/(?:(?:git/)?([0-9]+)(?:\.git)?/)? |
| ($PublicInbox::GitHTTPBackend::ANY)\z!ox) { |
| my ($epoch, $path) = ($2, $3); |
| invalid_inbox($ctx, $1) || serve_git($ctx, $epoch, $path); |
| } elsif ($path_info =~ m!$INBOX_RE/([a-zA-Z0-9_\-]+).mbox\.gz\z!o) { |
| serve_mbox_range($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/$END_RE\z!o) { |
| msg_page($ctx, $1, $2, $3); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/$ATTACH_RE\z!o) { |
| my ($idx, $fn) = ($3, $4); |
| invalid_inbox_mid($ctx, $1, $2) || get_attach($ctx, $idx, $fn); |
| # in case people leave off the trailing slash: |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/(T|t)\z!o) { |
| my ($inbox, $mid_ue, $suffix) = ($1, $2, $3); |
| $suffix .= $suffix =~ /\A[tT]\z/ ? '/#u' : '/'; |
| r301($ctx, $inbox, $mid_ue, $suffix); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/R/?\z!o) { |
| my ($inbox, $mid_ue) = ($1, $2); |
| r301($ctx, $inbox, $mid_ue, '#R'); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/$MID_RE/f/?\z!o) { |
| r301($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/_/text(?:/(.*))?\z!o) { |
| get_text($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/([a-zA-Z0-9_\-\.]+)\.css\z!o) { |
| get_css($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/manifest\.js\.gz\z!o) { |
| get_inbox_manifest($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/($OID_RE)/s/\z!o) { |
| get_vcs_object($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/($OID_RE)/s/ |
| ($PublicInbox::Hval::FN)\z!ox) { |
| get_vcs_object($ctx, $1, $2, $3); |
| } elsif ($path_info =~ m!$INBOX_RE/($OID_RE)/s\z!o) { |
| r301($ctx, $1, $2, 's/'); |
| } elsif ($path_info =~ m!$INBOX_RE/(\w+)\.sql\.gz\z!o) { |
| get_altid_dump($ctx, $1, $2); |
| # convenience redirects order matters |
| } elsif ($path_info =~ m!$INBOX_RE/([^/]{2,})\z!o) { |
| r301($ctx, $1, $2); |
| } elsif ($path_info =~ m!\A/\+/([a-zA-Z0-9_\-\.]+)\.css\z!) { |
| get_css($ctx, undef, $1); # for WwwListing |
| } else { |
| legacy_redirects($ctx, $path_info); |
| } |
| } |
| |
| # for CoW-friendliness, MOOOOO! Even for single-process setups, |
| # we want to get all immortal allocations done early to avoid heap |
| # fragmentation since common allocators favor a large contiguous heap. |
| sub preload { |
| my ($self) = @_; |
| |
| # populate caches used by Encode internally, since emails |
| # may show up with any encoding. |
| require Encode; |
| Encode::find_encoding($_) for Encode->encodings(':all'); |
| |
| require PublicInbox::ExtMsg; |
| require PublicInbox::Feed; |
| require PublicInbox::View; |
| require PublicInbox::SearchThread; |
| require PublicInbox::Eml; |
| require PublicInbox::Mbox; |
| require PublicInbox::ViewVCS; |
| require PublicInbox::WwwText; |
| require PublicInbox::WwwAttach; |
| eval { |
| require PublicInbox::Search; |
| PublicInbox::Search::load_xapian(); |
| }; |
| for (qw(SearchView MboxGz WwwAltId)) { |
| eval "require PublicInbox::$_;"; |
| } |
| if (ref($self)) { |
| my $pi_cfg = $self->{pi_cfg}; |
| if (defined($pi_cfg->{'publicinbox.cgitrc'})) { |
| $pi_cfg->limiter('-cgit'); |
| } |
| if ($pi_cfg->ALL) { |
| require PublicInbox::Isearch; |
| $pi_cfg->fill_all; |
| } |
| $self->cgit; |
| $self->coderepo; |
| $self->stylesheets_prepare($_) for ('', '../', '../../'); |
| $self->news_www; |
| } |
| } |
| |
| # private functions below |
| |
| sub r404 { |
| my ($ctx) = @_; |
| if ($ctx && $ctx->{mid}) { |
| require PublicInbox::ExtMsg; |
| return PublicInbox::ExtMsg::ext_msg($ctx); |
| } |
| r(404); |
| } |
| |
| sub news_cgit_fallback ($) { |
| my ($ctx) = @_; |
| my $res = $ctx->{www}->news_www->call($ctx->{env}); |
| |
| $res->[0] == 404 and ($ctx->{www}->{cgit_fallback} //= do { |
| my $c = $ctx->{www}->{pi_cfg}->{'publicinbox.cgit'} // 'first'; |
| $c ne 'first' # `fallback' and `rewrite' => true |
| } // 0) and $res = $ctx->{www}->coderepo->srv($ctx); |
| |
| ref($res) eq 'ARRAY' && $res->[0] == 404 and |
| $res = $ctx->{www}->cgit->call($ctx->{env}, $ctx); |
| |
| ref($res) eq 'ARRAY' && $res->[0] == 404 && |
| !$ctx->{www}->{cgit_fallback} and |
| $res = $ctx->{www}->coderepo->srv($ctx); |
| $res; |
| } |
| |
| # returns undef if valid, array ref response if invalid |
| sub invalid_inbox ($$) { |
| my ($ctx, $inbox) = @_; |
| my $ibx = $ctx->{www}->{pi_cfg}->lookup_name($inbox) // |
| $ctx->{www}->{pi_cfg}->lookup_ei($inbox); |
| if (defined $ibx) { |
| $ctx->{ibx} = $ibx; |
| return; |
| } |
| |
| # sometimes linkifiers (not ours!) screw up automatic link |
| # generation and link things intended for nntp:// to https?://, |
| # so try to infer links and redirect them to the appropriate |
| # list URL. |
| news_cgit_fallback($ctx); |
| } |
| |
| # returns undef if valid, array ref response if invalid |
| sub invalid_inbox_mid { |
| my ($ctx, $inbox, $mid_ue) = @_; |
| my $ret = invalid_inbox($ctx, $inbox); |
| return $ret if $ret; |
| |
| my $mid = $ctx->{mid} = uri_unescape($mid_ue); |
| my $ibx = $ctx->{ibx}; |
| if ($mid =~ m!\A([a-f0-9]{2})([a-f0-9]{38})\z!) { |
| my ($x2, $x38) = ($1, $2); |
| # this is horrifically wasteful for legacy URLs: |
| my $str = $ctx->{ibx}->msg_by_path("$x2/$x38") or return; |
| my $s = PublicInbox::Eml->new($str); |
| $mid = PublicInbox::MID::mid_clean($s->header_raw('Message-ID')); |
| return r301($ctx, $inbox, mid_escape($mid)); |
| } |
| undef; |
| } |
| |
| # /$INBOX/new.atom -> Atom feed, includes replies |
| sub get_atom { |
| my ($ctx) = @_; |
| require PublicInbox::Feed; |
| PublicInbox::Feed::generate($ctx); |
| } |
| |
| # /$INBOX/new.html -> HTML only |
| sub get_new { |
| my ($ctx) = @_; |
| require PublicInbox::Feed; |
| PublicInbox::Feed::new_html($ctx); |
| } |
| |
| # /$INBOX/topics_(new|active).(html|atom) |
| sub get_topics { |
| my ($ctx, $ibx_name, $category, $type) = @_; |
| require PublicInbox::WwwTopics; |
| PublicInbox::WwwTopics::response($ctx, $ibx_name, $category, $type); |
| } |
| |
| # /$INBOX/?r=$GIT_COMMIT -> HTML only |
| sub get_index { |
| my ($ctx) = @_; |
| require PublicInbox::Feed; |
| if ($ctx->{env}->{QUERY_STRING} =~ /(?:\A|[&;])q=/) { |
| require PublicInbox::SearchView; |
| PublicInbox::SearchView::sres_top_html($ctx); |
| } else { |
| PublicInbox::Feed::generate_html_index($ctx); |
| } |
| } |
| |
| # /$INBOX/$MESSAGE_ID/raw -> raw mbox |
| sub get_mid_txt { |
| my ($ctx) = @_; |
| require PublicInbox::Mbox; |
| PublicInbox::Mbox::emit_raw($ctx) || r(404); |
| } |
| |
| # /$INBOX/$MESSAGE_ID/ -> HTML content (short quotes) |
| sub get_mid_html { |
| my ($ctx) = @_; |
| require PublicInbox::View; |
| PublicInbox::View::msg_page($ctx) || r404($ctx); |
| } |
| |
| # /$INBOX/$MESSAGE_ID/t/ |
| sub get_thread { |
| my ($ctx, $flat) = @_; |
| $ctx->{ibx}->over or return need($ctx, 'Overview'); |
| $ctx->{flat} = $flat; |
| require PublicInbox::View; |
| PublicInbox::View::thread_html($ctx); |
| } |
| |
| # /$INBOX/_/text/$KEY/ |
| # /$INBOX/_/text/$KEY/raw |
| # KEY may contain slashes |
| sub get_text { |
| my ($ctx, $inbox, $key) = @_; |
| my $r404 = invalid_inbox($ctx, $inbox); |
| return $r404 if $r404; |
| |
| require PublicInbox::WwwText; |
| PublicInbox::WwwText::get_text($ctx, $key); |
| } |
| |
| # show git objects (blobs and commits) |
| # /$INBOX/$GIT_OBJECT_ID/s/ |
| # /$INBOX/$GIT_OBJECT_ID/s/$FILENAME |
| sub get_vcs_object ($$$;$) { |
| my ($ctx, $inbox, $oid, $filename) = @_; |
| my $r404 = invalid_inbox($ctx, $inbox); |
| return $r404 if $r404; |
| return r(404) if !$ctx->{www}->{pi_cfg}->repo_objs($ctx->{ibx}); |
| require PublicInbox::ViewVCS; |
| PublicInbox::ViewVCS::show($ctx, $oid, $filename); |
| } |
| |
| sub get_altid_dump { |
| my ($ctx, $inbox, $altid_pfx) =@_; |
| my $r404 = invalid_inbox($ctx, $inbox); |
| return $r404 if $r404; |
| eval { require PublicInbox::WwwAltId } or return need($ctx, 'sqlite3'); |
| PublicInbox::WwwAltId::sqldump($ctx, $altid_pfx); |
| } |
| |
| sub need { |
| my ($ctx, $extra, $upref) = @_; |
| require PublicInbox::WwwStream; |
| $upref //= '../'; |
| PublicInbox::WwwStream::html_oneshot($ctx, 501, <<EOF); |
| <pre>$extra is not available for this public-inbox |
| <a\nhref="$upref">Return to index</a></pre> |
| EOF |
| } |
| |
| # /$INBOX/$MESSAGE_ID/t.mbox -> thread as mbox |
| # /$INBOX/$MESSAGE_ID/t.mbox.gz -> thread as gzipped mbox |
| # note: I'm not a big fan of other compression formats since they're |
| # significantly more expensive on CPU than gzip and less-widely available, |
| # especially on older systems. Stick to zlib since that's what git uses. |
| sub get_thread_mbox { |
| my ($ctx, $sfx) = @_; |
| my $over = $ctx->{ibx}->over or return need($ctx, 'Overview'); |
| require PublicInbox::Mbox; |
| PublicInbox::Mbox::thread_mbox($ctx, $over, $sfx); |
| } |
| |
| |
| # /$INBOX/$MESSAGE_ID/t.atom -> thread as Atom feed |
| sub get_thread_atom { |
| my ($ctx) = @_; |
| $ctx->{ibx}->over or return need($ctx, 'Overview'); |
| require PublicInbox::Feed; |
| PublicInbox::Feed::generate_thread_atom($ctx); |
| } |
| |
| sub legacy_redirects { |
| my ($ctx, $path_info) = @_; |
| |
| # single-message pages |
| if ($path_info =~ m!$INBOX_RE/m/(\S+)/\z!o) { |
| r301($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/m/(\S+)/raw\z!o) { |
| r301($ctx, $1, $2, 'raw'); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/f/(\S+)/\z!o) { |
| r301($ctx, $1, $2); |
| |
| # thread display |
| } elsif ($path_info =~ m!$INBOX_RE/t/(\S+)/\z!o) { |
| r301($ctx, $1, $2, 't/#u'); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/t/(\S+)/mbox(\.gz)?\z!o) { |
| r301($ctx, $1, $2, "t.mbox$3"); |
| |
| # even older legacy redirects |
| } elsif ($path_info =~ m!$INBOX_RE/m/(\S+)\.html\z!o) { |
| r301($ctx, $1, $2); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/t/(\S+)\.html\z!o) { |
| r301($ctx, $1, $2, 't/#u'); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/f/(\S+)\.html\z!o) { |
| r301($ctx, $1, $2); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/(?:m|f)/(\S+)\.txt\z!o) { |
| r301($ctx, $1, $2, 'raw'); |
| |
| } elsif ($path_info =~ m!$INBOX_RE/t/(\S+)(\.mbox(?:\.gz)?)\z!o) { |
| r301($ctx, $1, $2, "t$3"); |
| |
| # legacy convenience redirects, order still matters |
| } elsif ($path_info =~ m!$INBOX_RE/m/(\S+)\z!o) { |
| r301($ctx, $1, $2); |
| } elsif ($path_info =~ m!$INBOX_RE/t/(\S+)\z!o) { |
| r301($ctx, $1, $2, 't/#u'); |
| } elsif ($path_info =~ m!$INBOX_RE/f/(\S+)\z!o) { |
| r301($ctx, $1, $2); |
| |
| # some Message-IDs have slashes in them and the HTTP server |
| # may try to be clever and unescape them :< |
| } elsif ($path_info =~ m!$INBOX_RE/(\S+/\S+)/$END_RE\z!o) { |
| msg_page($ctx, $1, $2, $3); |
| |
| # in case people leave off the trailing slash: |
| } elsif ($path_info =~ m!$INBOX_RE/(\S+/\S+)/(T|t)\z!o) { |
| r301($ctx, $1, $2, $3 eq 't' ? 't/#u' : $3); |
| } elsif ($path_info =~ m!$INBOX_RE/(\S+/\S+)/f\z!o) { |
| r301($ctx, $1, $2); |
| } else { |
| news_cgit_fallback($ctx); |
| } |
| } |
| |
| sub r301 { |
| my ($ctx, $inbox, $mid_ue, $suffix) = @_; |
| my $ibx = $ctx->{ibx}; |
| unless ($ibx) { |
| my $r404 = invalid_inbox($ctx, $inbox); |
| return $r404 if $r404; |
| $ibx = $ctx->{ibx}; |
| } |
| my $url = $ibx->base_url($ctx->{env}); |
| my $qs = $ctx->{env}->{QUERY_STRING}; |
| if (defined $mid_ue) { |
| # common, and much nicer as '@' than '%40': |
| $mid_ue =~ s/%40/@/g; |
| $url .= $mid_ue . '/'; |
| } |
| $url .= $suffix if (defined $suffix); |
| $url .= "?$qs" if $qs ne ''; |
| |
| [ 301, |
| [ Location => $url, 'Content-Type' => 'text/plain' ], |
| [ "Redirecting to $url\n" ] ] |
| } |
| |
| sub msg_page { |
| my ($ctx, $inbox, $mid_ue, $e) = @_; |
| my $ret; |
| $ret = invalid_inbox_mid($ctx, $inbox, $mid_ue) and return $ret; |
| '' eq $e and return get_mid_html($ctx); |
| 'T/' eq $e and return get_thread($ctx, 1); |
| 't/' eq $e and return get_thread($ctx); |
| 't.atom' eq $e and return get_thread_atom($ctx); |
| 't.mbox' eq $e and return get_thread_mbox($ctx); |
| 't.mbox.gz' eq $e and return get_thread_mbox($ctx, '.gz'); |
| 'raw' eq $e and return get_mid_txt($ctx); |
| |
| # legacy, but no redirect for compatibility: |
| 'f/' eq $e and return get_mid_html($ctx); |
| if ($e eq 'd/') { |
| require PublicInbox::View; |
| return PublicInbox::View::diff_msg($ctx); |
| } |
| r404($ctx); |
| } |
| |
| sub serve_git { |
| my ($ctx, $epoch, $path) = @_; |
| my $env = $ctx->{env}; |
| my $ibx = $ctx->{ibx}; |
| my $git = defined $epoch ? $ibx->git_epoch($epoch) : $ibx->git; |
| $git ? PublicInbox::GitHTTPBackend::serve($env, $git, $path) : r404(); |
| } |
| |
| sub mbox_results { |
| my ($ctx) = @_; |
| if ($ctx->{env}->{QUERY_STRING} =~ /(?:\A|[&;])q=/) { |
| $ctx->{ibx}->isrch or return need($ctx, 'search'); |
| require PublicInbox::SearchView; |
| return PublicInbox::SearchView::mbox_results($ctx); |
| } |
| r404(); |
| } |
| |
| sub serve_mbox_range { |
| my ($ctx, $inbox, $range) = @_; |
| invalid_inbox($ctx, $inbox) || eval { |
| require PublicInbox::Mbox; |
| PublicInbox::Mbox::emit_range($ctx, $range); |
| } |
| } |
| |
| sub news_www { |
| my ($self) = @_; |
| $self->{news_www} //= do { |
| require PublicInbox::NewsWWW; |
| PublicInbox::NewsWWW->new($self->{pi_cfg}); |
| } |
| } |
| |
| sub cgit { |
| my ($self) = @_; |
| $self->{cgit} //= |
| (defined($self->{pi_cfg}->{'publicinbox.cgitrc'}) ? do { |
| require PublicInbox::Cgit; |
| PublicInbox::Cgit->new($self->{pi_cfg}); |
| } : undef) // do { |
| require Plack::Util; |
| Plack::Util::inline_object(call => sub { r404() }); |
| }; |
| } |
| |
| sub coderepo { |
| my ($self) = @_; |
| $self->{coderepo} //= do { |
| require PublicInbox::WwwCoderepo; |
| PublicInbox::WwwCoderepo->new($self->{pi_cfg}); |
| } |
| } |
| |
| # GET $INBOX/manifest.js.gz |
| sub get_inbox_manifest ($$$) { |
| my ($ctx, $inbox, $key) = @_; |
| my $r404 = invalid_inbox($ctx, $inbox); |
| return $r404 if $r404; |
| require PublicInbox::ManifestJsGz; |
| PublicInbox::ManifestJsGz::per_inbox($ctx); |
| } |
| |
| sub get_attach { |
| my ($ctx, $idx, $fn) = @_; |
| require PublicInbox::WwwAttach; |
| PublicInbox::WwwAttach::get_attach($ctx, $idx, $fn); |
| } |
| |
| # User-generated content (UGC) may have excessively long lines |
| # and screw up rendering on some browsers, so we use pre-wrap. |
| # |
| # We also force everything to the same scaled font-size because GUI |
| # browsers (tested both Firefox and surf (webkit)) uses a larger font |
| # for the Search <form> element than the rest of the page. Font size |
| # uniformity is important to people who rely on gigantic fonts. |
| # Finally, we use monospace to ensure the Search field and button |
| # has the same size and spacing as everything else which is |
| # <pre>-formatted anyways. |
| our $STYLE = 'pre{white-space:pre-wrap}*{font-size:100%;font-family:monospace}'; |
| |
| sub _read_css ($$$) { |
| my ($fh, $mini, $fn) = @_; |
| my $mtime = 0; |
| my $local = PublicInbox::IO::read_all $fh; # sets _ |
| if ($local =~ /\S/) { |
| $mtime = sprintf('%x',(stat(_))[9]); |
| $local = $mini->($local); |
| } |
| # do not let BOFHs override userContent.css: |
| return ($local, $mtime) if $local !~ /!\s*important\b/i; |
| warn "W: ignoring $fn since it uses `!important'\n"; |
| (); |
| } |
| |
| sub stylesheets_prepare ($$) { |
| my ($self, $upfx) = @_; |
| my $mini = eval { |
| require CSS::Minifier; |
| sub { CSS::Minifier::minify(input => $_[0]) }; |
| } || eval { |
| require CSS::Minifier::XS; |
| sub { CSS::Minifier::XS::minify($_[0]) }; |
| } || sub { $_[0] }; |
| |
| my $css_map = $self->{-css_map} //= {}; |
| my $stylesheets = $self->{pi_cfg}->{css} || []; |
| my $links = []; |
| my $inline_ok = 1; |
| my (%css_dir, @css_dir, $import); |
| |
| foreach my $s (@$stylesheets) { |
| my $attr = {}; |
| local $_ = $s; |
| foreach my $k (qw(media title href load)) { |
| if (s/\s*$k='([^']+)'// || s/\s*$k=(\S+)//) { |
| $attr->{$k} = $1; |
| } |
| } |
| # we may support `last' (to load CSS last at </html>) |
| # or other load directives in the future... |
| for my $l (split /,/, delete $attr->{load} // '') { |
| if ($l eq 'import') { |
| $inline_ok = 0; |
| $import = $attr->{-do_import} = 1; |
| } elsif ($l eq 'link') { |
| $inline_ok = 0; |
| } else { |
| warn "W: load=$l not recognized (ignored)\n"; |
| } |
| } |
| if (defined $attr->{href}) { |
| $inline_ok = 0; |
| } else { |
| my ($fn, $dir, $key); |
| $fn = $_; |
| ($dir, $key) = (m!\A(?:(.+?)/)?([^/]+?)(?:\.css)?\z!i); |
| if ($key !~ /\A[a-zA-Z0-9_\-\.]+\z/) { |
| warn "ignoring $fn, non-ASCII word character\n"; |
| next; |
| } |
| my ($local, $mtime); |
| if (my $rec = $css_map->{$key}) { # already loaded |
| ($local, $mtime) = @$rec; |
| } elsif (open(my $fh, '<', $fn)) { |
| ($local, $mtime) = _read_css $fh, $mini, $fn; |
| if ($local =~ /\@import\b/) { |
| $import = $attr->{-do_import} = 1; |
| push @css_dir, $dir if !$css_dir{$dir}++ |
| } |
| $css_map->{$key} = [ $local, $mtime ]; |
| } else { |
| warn "failed to open $fn: $!\n"; |
| next; |
| } |
| |
| $attr->{href} = "$upfx$key.css?$mtime"; |
| if (defined($attr->{title})) { # browser-selectable |
| $inline_ok = 0; |
| } elsif (($attr->{media}||'screen') eq 'screen') { |
| $attr->{-inline} = $local; |
| } |
| } |
| push @$links, $attr; |
| } |
| my $buf = '<style>'; |
| if ($import) { |
| my @links; |
| for my $attr (@$links) { |
| if (delete $attr->{-do_import}) { |
| $buf .= '@import url("'.$attr->{href}.'") '. |
| $attr->{media}.';'; |
| } else { |
| push @links, $attr; |
| } |
| } |
| $links = \@links; |
| } |
| # can't have $STYLE before @import(?) |
| # <https://developer.mozilla.org/en-US/docs/Web/CSS/@import> |
| $buf .= $STYLE; |
| if ($inline_ok) { |
| my @ext; # for media=print and whatnot |
| foreach my $attr (@$links) { |
| if (defined(my $str = delete $attr->{-inline})) { |
| $buf .= $str; |
| } else { |
| push @ext, $attr; |
| } |
| } |
| $links = \@ext; |
| } |
| $buf .= '</style>'; |
| |
| if (@$links) { |
| foreach my $attr (@$links) { |
| delete $attr->{-inline}; |
| $buf .= "<link\ntype=text/css\nrel=stylesheet"; |
| while (my ($k, $v) = each %$attr) { |
| $v = qq{"$v"} if $v =~ /[\s=]/; |
| $buf .= qq{\n$k=$v}; |
| } |
| $buf .= ' />'; |
| } |
| $self->{"-style-$upfx"} = $buf; |
| } else { |
| $self->{-style_inline} = $buf; |
| } |
| # load potentially imported CSS files in known CSS directories |
| if (@css_dir && !$self->{-css_dir}) { |
| opendir my $cwddh, '.'; |
| for my $d (@css_dir) { |
| CORE::opendir my $dh, $d or do { |
| warn "W: opendir($d): $!"; |
| next; |
| }; |
| chdir $dh; |
| my @css = grep /\.css\z/i, readdir $dh; |
| for my $fn (@css) { |
| my ($key) = ($fn =~ m!([^/]+?)(?:\.css)?\z!i); |
| next if $css_map->{$key}; |
| -f $fn or next; |
| # no warning for autoloaded CSS |
| open my $fh, '<', $fn or next; |
| -T $fh or next; |
| my ($local, $mtime) = |
| _read_css $fh, $mini, "$d/$fn"; |
| $css_map->{$key} = [ $local, $mtime ]; |
| } |
| chdir $cwddh; |
| } |
| $self->{-css_dir} = \@css_dir; |
| } |
| $css_map; |
| } |
| |
| # returns an HTML fragment with <style> or <link> tags in them |
| # Called by WwwStream by nearly every HTML page |
| sub style { |
| my ($self, $upfx) = @_; |
| $self->{-style_inline} || $self->{"-style-$upfx"} || do { |
| stylesheets_prepare($self, $upfx); |
| $self->{-style_inline} || $self->{"-style-$upfx"} |
| }; |
| } |
| |
| # /$INBOX/$KEY.css and /+/$KEY.css endpoints |
| # CSS is configured globally for all inboxes, but we access them on |
| # a per-inbox basis. This allows administrators to setup per-inbox |
| # static routes to intercept the request before it hits PSGI |
| # inbox == undef => top-level WwwListing |
| sub get_css ($$$) { |
| my ($ctx, $inbox, $key) = @_; |
| my $r404 = defined($inbox) ? invalid_inbox($ctx, $inbox) : undef; |
| return $r404 if $r404; |
| my $self = $ctx->{www}; |
| my $css_map = $self->{-css_map} || |
| stylesheets_prepare($self, defined($inbox) ? '' : '+/'); |
| my $rec = $css_map->{$key}; |
| if (!defined($rec) && defined($inbox) && $key eq 'userContent') { |
| $rec = [ PublicInbox::UserContent::sample($ctx) ]; |
| } |
| srv_css_rec($rec); |
| } |
| |
| sub srv_css_rec { # $_[0] may be ctx->{www} |
| my $rec = $_[-1] // return r404(); |
| my ($css, undef) = @$rec; # TODO: Last-Modified + If-Modified-Since |
| my $h = [ 'Content-Length', length($css), 'Content-Type', 'text/css' ]; |
| PublicInbox::GitHTTPBackend::cache_one_year($h); |
| [ 200, $h, [ $css ] ]; |
| } |
| |
| sub get_description { |
| my ($ctx, $inbox) = @_; |
| invalid_inbox($ctx, $inbox) || do { |
| my $d = $ctx->{ibx}->description . "\n"; |
| utf8::encode($d); |
| [ 200, [ 'Content-Length', length($d), |
| 'Content-Type', 'text/plain' ], [ $d ] ]; |
| }; |
| } |
| |
| sub event_step { # called via requeue |
| my ($self) = @_; |
| # gzf = PublicInbox::GzipFilter == $ctx |
| my $gzf = shift(@{$self->{-low_prio_q}}) // return; |
| PublicInbox::DS::requeue($self) if scalar(@{$self->{-low_prio_q}}); |
| my $http = $gzf->{env}->{'psgix.io'}; # PublicInbox::HTTP |
| $http->next_step($gzf->can('async_next')); |
| } |
| |
| 1; |