App-news

 view release on metacpan or  search on metacpan

script/news  view on Meta::CPAN

    NNTPSERVER=cosmic.voyage news daemon

The remote news server but only the C<campaignwiki.*> groups, with the pattern
in quotes to prevent shell expansion:

    NNTPSERVER=campaignwiki.org "NEWS_GROUPS=campaignwiki.*" news daemon

The remote news server with all the groups except any C<*.test> groups, with the
pattern in quotes to prevent shell expansion. The C<sn> server can't parse this
pattern, unfortunately.

    NNTPSERVER=campaignwiki.org "NEWS_GROUPS=*,!*.test" news daemon

The local news server requires no authorisation.

    NNTPSERVER=localhost NEWS_MODE=NOAUTH news daemon

The news server requires authorisation and we want to point visitors to a first
post. We assume that NNTPSERVER or NEWSHOST is already set.

    NEWS_INTRO_ID='<u4d0i0$n72d$1@sibirocobombus.campaignwiki>' news daemon

As a developer, run it under C<morbo> so that we can make changes to the script.
Provide the path to the script. This time with regular authorisation.

    PERL5LIB=lib NNTPSERVER=localhost morbo script/news

=head1 SEE ALSO

The Tildeverse also runs news. L<https://news.tildeverse.org/>

L<RFC 3977|https://www.rfc-editor.org/rfc/rfc3977>: Network News Transfer
Protocol (NNTP).

L<RFC 3987|https://www.rfc-editor.org/rfc/rfc3987>: Internationalized Resource
Identifiers (IRIs)

L<RFC 4643|https://www.rfc-editor.org/rfc/rfc4643>: Network News Transfer
Protocol (NNTP) Extension for Authentication.

L<RFC 5536|https://www.rfc-editor.org/rfc/rfc5536>: Netnews Article Format.

L<RFC 5537|https://www.rfc-editor.org/rfc/rfc5537>: Netnews Architecture and
Protocols.

L<RFC 8315|https://www.rfc-editor.org/rfc/rfc8315>: Cancel-Locks in Netnews
Articles

=head1 LICENSE

GNU Affero General Public License

=cut

# corelist
use Net::NNTP;
use Encode qw(encode decode);
# not core
use Mojolicious::Lite;      # Mojolicious
use Mojo::Cache;
use DateTime::Format::Mail;
use List::Util qw(first);
use utf8;
# our own
use App::news qw(wrap html_unwrap ranges);

my $cache = Mojo::Cache->new;

get '/' => sub {
  shift->redirect_to('index');
};

under 'news';

get '/' => sub {
  my $c = shift;
  my $list = cached("active " . ($ENV{NEWS_GROUPS} || "*"), sub {
    my $nntp = Net::NNTP->new() or return 'error';
    my $value = $nntp->active($ENV{NEWS_GROUPS} || "*");
    $nntp->quit;
    return $value });
  return $c->render(template => 'noserver') if $list eq 'error';
  $c->render(template => 'index', list => $list,
             id => $ENV{NEWS_INTRO_ID},
             address => $c->tx->req->url->to_abs->host);
} => 'index';

sub cached {
  my ($key, $sub) = @_;
  my $cached = $cache->get($key);
  my $value;
  if (defined $cached) {
    my ($ts, $data) = @$cached;
    my $age = time - $ts;
    app->log->debug("Cache age of $key: ${age}s");
    $value = $data if $age <= 5 * 60; # cached for five minutes
  }
  if (not defined $value) {
    app->log->debug("Getting a fresh copy of $key");
    $value = $sub->();
    $cache->set($key => [time, $value]);
  }
  return $value;
}

my $per_page = 50;
my $per_search = 500;

get '/group/#group' => sub {
  my $c = shift;
  my $group = $c->param('group');
  my $edit = $c->param('edit');
  my $page = $c->param('page') || "";
  my $nntp; # only created on demand
  my $description = cached("$group description", sub {
    $nntp ||= Net::NNTP->new() or return 'error';
    my $newsgroups = $nntp->newsgroups($group);
    return $newsgroups && $newsgroups->{$group} || "" });
  return $c->render(template => 'noserver') if 'error' eq $description;
  my $data = cached("$group list $page", sub {
    $nntp ||= Net::NNTP->new() or return 'error';
    my ($nums, $first, $last) = $nntp->group($group) or return [];
    my $last_page = int($last / $per_page) + 1;
    $page ||= $last_page;
    my $to = $page * $per_page;
    $to = $last if $to > $last;
    my $from = ($page - 1) * $per_page;
    $from = $first if $from < $first;
    my $fmt = $nntp->overview_fmt;
    app->log->debug("Getting $group $from-$to");
    my $messages = $nntp->xover("$from-$to");
    my $articles = [];
    my $parser = DateTime::Format::Mail->new->loose;
    for my $num (sort { $b <=> $a } keys %$messages) {
      my ($subject, $from, $date, $id, $references) = @{$messages->{$num}};
      $subject = decode("MIME-Header", $subject) || "?";
      my ($tag) = $subject =~ /\[(.*?)\]/;
      $from = no_email(decode("MIME-Header", $from));
      my $dt = $parser->parse_datetime($date);
      my $url = $c->url_for('article', group => $group, id => $num);
      $url = $url->query(edit => $edit) if $edit;
      push(@$articles, {
        id => $id,
        num => $num,
        tag => $tag,
        url => $url,
        from => $from,
        subject => $subject,
        date => [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)],
        references => [split(/\s+/, decode("MIME-Header", $references))],
        replies => [] })
    };
    # link replies based on references but only the articles on the same pages (!)
    for my $article (@$articles) {
      for my $reference (@{$article->{references}}) {
        my $original = first { $reference eq $_->{id} } @$articles;
        next unless $original;
        push(@{$original->{replies}}, $article->{id});
        app->log->debug("$article->{id} is a reply to $original->{id}");
      }
    }
    return {
      articles => $articles,
      pagination => {page => $page, last_page => $last_page}}});
  return $c->render(template => 'noserver') if 'error' eq $data;
  $nntp->quit if $nntp;
  $c->render(template => 'group', group => $group, edit => $edit, description => $description,
             list => $data->{articles}, pagination => $data->{pagination});
} => 'group';

sub no_email {
  my $from = shift;
  $from =~ s/\s*<.*>//;
  $from =~ s/\s*"\S+@\S+"//;
  $from =~ s/\S+@\S+\s+\((.*?)\)/$1/;
  return $from || "Anonymous";
}

get '/tag/#group/#tag' => sub {
  my $c = shift;
  my $group = $c->param('group');
  my $edit = $c->param('edit');
  my $tag = $c->param('tag');
  # We start counting in the back… This is different from the /group list.
  # There, we take the first and last message numbers and compute page numbers
  # based on that. Starting at the front makes this stable. The same articles
  # stay on the same pages. Given first and last article numbers and a search
  # pattern, we can't do this. Therefore, we start at the present and scan into
  # the past until we have the page we want.
  my $page = $c->param('page') // 0;
  my $include = $c->param('include') // 0;
  my $nntp; # only created on demand
  my $data = cached("$group tag $tag", sub {
    $nntp ||= Net::NNTP->new() or return 'error';
    my ($nums, $first, $last) = $nntp->group($group) or return [];
    app->log->debug("$group has $first-$last");
    my $seen = 0; # set when we have seen $include
    my $to = $last;
    my $from = $to - $per_search;
    $from = $first if $from < $first;
    my $pattern = "*\\[$tag\\]*";
    $pattern =~ s/ /?/g;
    my $result = $nntp->xpat("Subject", $pattern, [$from, $to]);
    my @nums = sort keys %$result;
    app->log->debug("Searching pattern $pattern $from-$to found " . scalar(@nums) . " articles");
    $seen = grep { $_ == $include } @nums if $include;
    # keep checking more, if necessary
    while (($page and @nums / $per_page < $page
            or $include and not $seen)
           and $from > $first) {
      $to -= $per_search;
      $from -= $per_search;
      $from = $first if $from < $first;
      $result = $nntp->xpat("Subject", $pattern, [$from, $to]);
      app->log->debug("Searching pattern $pattern $from-$to found " . scalar(@nums) . " articles");
      $seen = grep { $_ == $include } keys %$result if $include;
      unshift(@nums, sort keys %$result);
    }
    # add pagination
    if ($page) {
      @nums = @nums[(-$page-1) * $per_page + 1, -$page * $per_page];
    } elsif ($include) {
      my @page;
      while (@nums > $per_page and not grep { $_ == $include } @page) {
        @page = splice(@nums, -$per_page);
        $page++;
      }
      @nums = @page if @page;
    }
    my $ranges = ranges(@nums);
    my $fmt = $nntp->overview_fmt;
    my $re = quotemeta($tag);
    my $articles = [];
    my $parser = DateTime::Format::Mail->new->loose;
    for my $range (@$ranges) {
      app->log->debug("Getting $group " . (ref $range ? join("-", @$range) : $range));
      my $messages = $nntp->xover($range);
      app->log->debug("Received " . scalar(keys %$messages) . " messages");
      for my $num (sort keys %$messages) {
        my ($subject, $from, $date, $id, $references) = @{$messages->{$num}};
        $subject = decode("MIME-Header", $subject) || "?";
        $subject =~ s/\[$re\]\s*//;
        $from = no_email(decode("MIME-Header", $from));
        my $dt = $parser->parse_datetime($date);
        my $url = $c->url_for('article', group => $group, id => $num);
        $url = $url->query(edit => $edit) if $edit;
        push(@$articles, {
          id => $id,
          num => $num,
          url => $url,
          from => $from,
          subject => $subject,
          date => [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)],
          references => [split(/\s+/, decode("MIME-Header", $references))],
          replies => [] });
      }
    }
    # link replies based on references but only the articles on the same page (!)
    for my $article (@$articles) {
      for my $reference (@{$article->{references}}) {
        my $original = first { $reference eq $_->{id} } @$articles;
        next unless $original;
        push(@{$original->{replies}}, $article->{id});
        app->log->debug("$article->{id} is a reply to $original->{id}");
      }
    }
    # reverse the list of articles, latest ones come first
    return [reverse @$articles]});
  return $c->render(template => 'noserver') if 'error' eq $data;
  $nntp->quit if $nntp;
  # If the cached data did not include our article, delete the cache and retry.
  # This could be optimized to extend the existing data…
  if ($include and (@$data == 0 or $include < $data->[$#$data]->{num})) {
    my $seen = grep { $_->{num} == $include } @$data;
    if (not $seen) {
      app->log->debug("$include was not seen in the cached data");
      $cache->set("$group tag $tag" => undef);
      return $c->redirect_to('tag');
    }
  }
  $c->render(template => 'tag', group => $group, tag => $tag, edit => $edit, list => $data);
} => 'tag';

# This only works for message-ids, not for message numbers (since they require a
# group).
get '/article/#id' => sub {
  my $c = shift;
  show_article($c, $c->param('id'));
} => 'article_id';

get '/article/#group/#id' => sub {
  my $c = shift;
  show_article($c, $c->param('id'), $c->param('group'));
} => 'article';

sub show_article {
  # When following a link from the group, $id_or_num is a num and $group is
  # important. When following a reference from an article, $id_or_num is a
  # message-id and $group is only used for the reply form.
  my ($c, $id_or_num, $group) = @_;
  my $article = cached("$group article $id_or_num", sub {
    my $nntp = Net::NNTP->new() or return 'noserver';
    $nntp->group($group) if $group;
    my $article = $nntp->article($id_or_num);
    return 'unknown' unless $article;
    # app->log->trace(join("", @$article));
    # $article is header lines, an empty line, and body lines
    my $headers = Mojo::Headers->new;
    while ($_ = shift(@$article)) {
      $headers->parse("$_\r\n");
      last unless /\S/;
    }
    my $id = $headers->header("message-id");
    my $subject = decode("MIME-Header", $headers->header("subject")) || "?";
    my $from = no_email(decode("MIME-Header", $headers->header("from")));
    my $date = $headers->header("date");
    my $dt = DateTime::Format::Mail->new->loose->parse_datetime($date);
    $date = [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)];
    my $newsgroups = [split(/\s*,\s*/, decode("MIME-Header", $headers->header("newsgroups")) || "")];
    $group ||= "@$newsgroups";
    my $references = [split(/\s+/, decode("MIME-Header", $headers->header("references")) || "")];
    my $body = join("", @$article);
    $body =~ s/\s*<\S*?@\S*?>//g; # remove email addresses
    $body =~ s/\s*"\S*?@\S*?"//g; # remove email addresses
    if ($headers->header('content-type')) {
      my ($charset) = $headers->header('content-type') =~ /charset=['"]?([^;'"]*)/;
      $body = decode($charset, $body) if $charset;
    }
    my $value = {
      id => $id,
      group => $group,
      from => $from,
      subject => $subject,
      date => $date,
      newsgroups => $newsgroups,
      references => $references,
      html_body => html_unwrap($body),
      body => $body,
    };
    # perhaps we have cached replies from looking at the group (space and no page number at the end)
    my $cached_group = cached("$group list ", sub {}) || {};
    my $cached_article = (first { $_->{id} eq $id } @{$cached_group->{articles}}) || {};
    $value->{replies} = $cached_article->{replies} || [];
    app->log->debug("$id replies: @{$value->{replies}}");
    $nntp->quit;
    # If $id_or_num was a number, add a second key to the cache in case we need
    # the same article but following a reference.
    $cache->set("$group article $id" => [time, $value]) if $id_or_num ne $id;
    return $value });
  return $c->render(template => $article) unless ref $article;
  $c->render(template => 'article', article => $article, edit => $c->param('edit'));
}

get '/post/#group' => sub {
  my $c = shift;
  # copy from the cookie
  $c->param($_ => $c->session->{$_}) for qw(name username password);
  $c->render(template => 'post',
             id => '',
             subject => '',
             supersedes => '',
             references => '');
} => 'new';

post '/reply' => sub {
  my $c = shift;
  # copy from the cookie
  $c->param($_ => $c->session->{$_}) for qw(name username password);
  $c->render(template => 'post',
             id => $c->param('id'),
             group => $c->param('group'),
             subject => $c->param('subject'),
             supersedes => '',
             references => $c->param('references'));
} => 'reply';

post '/supersede' => sub {

script/news  view on Meta::CPAN

  $c->session(expiration => time + 7 * 24 * 60 * 60); # one week
  my $username = $c->param('username');
  return $c->error("No username") unless $username or $ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOAUTH";
  $c->session(username => $username);

  my $password = $c->param('password');
  return $c->error("No password") unless $password or $ENV{NEWS_MODE} and $ENV{NEWS_MODE}eq "NOAUTH";
  $c->session(password => $password);

  my $name = $c->param('name');
  return $c->error("No from address specified") unless $name;
  $name =~ s/[^[:graph:] ]//g;
  return $c->error("From address does not have the format 'Your Name <mail\@example.org>'") unless $name =~ /<\S+@\S+\.\S+>/;
  $c->session(name => $name);

  my $group = $c->param('group');
  return $c->error("No group") unless $group;
  $group =~ s/[^[:graph:]]//g;
  return $c->error("No group") unless $group;

  my $references = $c->param('references');
  my $supersedes = $c->param('supersedes');

  my $subject = $c->param('subject');
  return $c->error("No subject") unless $subject;
  # $subject = encode("MIME-Header", $subject);

  my $body = $c->param('body');
  return $c->error("No body") unless $body;

  $body = wrap($body) if $c->param('wrap');

  my $nntp = Net::NNTP->new() or return $c->render(template => 'noserver');
  $nntp->authinfo($username, $password) if $username and $password;
  my $article = [];
  push(@$article, "From: $name\r\n");
  push(@$article, "Subject: $subject\r\n");
  push(@$article, "Newsgroups: $group\r\n");
  push(@$article, "References: $references\r\n") if $references;
  push(@$article, "Supersedes: $supersedes\r\n") if $supersedes;
  push(@$article, "MIME-Version: 1.0\r\n");
  push(@$article, "Content-Type: text/plain; charset=UTF-8\r\n");
  push(@$article, "Content-Transfer-Encoding: 8bit\r\n");
  push(@$article, "\r\n");
  push(@$article, map { "$_\r\n" } split(/\r?\n/, encode('UTF-8', $body)));
  app->log->debug(join("", @$article));
  my $ok = $nntp->post($article);
  $cache->set("$group list " => undef) if $ok; # includes space and no page number
  $nntp->quit;
  $c->render('posted', group => $group, ok => $ok);
} => 'post';

get '/latest' => sub {
  my $c = shift;
  my $list = cached("news " . ($ENV{NEWS_GROUPS} || "*"), sub {
    my $nntp = Net::NNTP->new() or return 'error';
    my $since = time() - 7 * 24 * 60 * 60; # one week
    my $ids = $nntp->newnews($since, $ENV{NEWS_GROUPS} || "*");
    $ids = [@$ids[$#$ids - $per_page .. $#$ids]] if @$ids > $per_page;
    my $articles = [];
    my $parser = DateTime::Format::Mail->new->loose;
    for my $id (@$ids) {
      my $head = $nntp->head($id);
      next unless $head;
      my $headers = Mojo::Headers->new;
      for my $line (@$head) {
        $headers->parse("$line\r\n");
      }
      $headers->parse("\r\n"); # make sure it finishes correctly
      my $subject = decode("MIME-Header", $headers->header("subject")) || "?";
      my ($tag) = $subject =~ /\[(.*?)\]/;
      my $from = no_email(decode("MIME-Header", $headers->header("from")));
      my $date = $headers->header("date");
      app->log->debug("$from/$subject/$date") unless $date;
      my $dt = $parser->parse_datetime($date);
      $date = [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)];
      my $newsgroups = [split(/\s*,\s*/, decode("MIME-Header", $headers->header("newsgroups")) || "")];
      my $group = "@$newsgroups";
      my $url = $c->url_for('article', group => $group, id => $id); # $num is not available
      push(@$articles, {
        id => $id,
        tag => $tag,
        url => $url,
        group => $group,
        from => $from,
        subject => $subject,
        date => $date,
        newsgroups => $newsgroups, });
    };
    $nntp->quit;
    return $articles });
  return $c->render(template => 'noserver') if $list eq 'error';
  $c->render(template => 'latest', list => $list);
} => 'latest';

app->start;

__DATA__

@@ index.html.ep
% layout "default";
% title 'News';
<h1>News</h1>
<p>
This is a forum. The groups and posts it shows are from a <a
href="https://en.wikipedia.org/wiki/News_server">news server</a>. If you have a
web browser that knows how to handle news URLs, like <tt>lynx</tt>, you can
visit the news server <a href="news://<%= $address %>/">directly</a>.

<p>
% if ($id) {
<%= link_to url_for('article_id', id => $id) => begin %>Start here<% end %>.
% }
<%= link_to url_for('latest') => begin %>Latest posts<% end %>.

<table>
<tr><th class="status">Post</th><th>Group</th></tr>
% my @seen;
% for my $group (sort keys %$list) {
%   my ($last, $first, $flag) = @{$list->{$group}};
%   my $status = "";



( run in 0.552 second using v1.01-cache-2.11-cpan-5735350b133 )