blob: 420132d9e8818ecc709ce2901d7a9f226fd6b3c2 [file] [log] [blame]
# 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;