File Coverage

script/news
Criterion Covered Total %
statement 76 78 97.4
branch 11 20 55.0
condition 13 20 65.0
subroutine 12 12 100.0
pod n/a
total 112 130 86.1


line stmt bran cond sub pod time code
1             #! /usr/bin/env perl
2             # Copyright (C) 2023 Alex Schroeder <alex@gnu.org>
3              
4             # This program is free software: you can redistribute it and/or modify it under
5             # the terms of the GNU Affero General Public License as published by the Free
6             # Software Foundation, either version 3 of the License, or (at your option) any
7             # later version.
8             #
9             # This program is distributed in the hope that it will be useful, but WITHOUT
10             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11             # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12             # details.
13             #
14             # You should have received a copy of the GNU Affero General Public License along
15             # with this program. If not, see <https://www.gnu.org/licenses/>.
16              
17             =encoding utf8
18              
19             =head1 NAME
20              
21             news - a web front-end to a local news server
22              
23             =head1 SYNOPSIS
24              
25             B<news>
26              
27             =head1 DESCRIPTION
28              
29             C<news> connects to the local news server via NNTP on port 119 and offers a
30             web interface for it.
31              
32             There are a number of views available:
33              
34             =over
35              
36             =item * the list of newsgroups available ("server view");
37              
38             =item * the list of articles available in a particular newsgroup ("group view");
39              
40             =item * a list of articles with a particular tag in a newsgroup ("tag view");
41              
42             =item * an article ("article view");
43              
44             =item * a reply;
45              
46             =item * a new post.
47              
48             =back
49              
50             When showing From fields, the value is stripped of things that look like email
51             addresses in angled brackets such as <alex@gnu.org> or in double quotes such as
52             "alex@gnu.org"; if an email address is followed by a real name in parenthesis
53             such as alex@gnu.org (Alex Schroeder), the address and the parenthesis are
54             stripped. If no full name is provided, "Anonymous" is used.
55              
56             In the article view, email addresses in angled brackets such as <alex@gnu.org>
57             or in double quotes such as "alex@gnu.org" are also stripped. Other things that
58             might look like email addresses are not stripped.
59              
60             =head2 Threading
61              
62             Technically, articles only have references back in time. In order to show links
63             to replies, the article view relies on a cache of the group view. If the group
64             view isn't in the cache, replies cannot be shown.
65              
66             =head2 Caching
67              
68             All the NNTP requests are cached for 5min. The cache relies on L<Mojo::Cache>.
69             That cache only holds 100 items by default, so on a busy server, NNTP requests
70             might get cached for less time. The cache isn't written to disk, so if you're a
71             developer, you can restart the server to empty the cache instead of waiting for
72             5min.
73              
74             =head2 Tags
75              
76             When an article's subject contains a string in square brackets C<[like this]>,
77             then this is treated as a tag. Click on the tag to see the tag view containing
78             articles with the same tag, irrespective of threading.
79              
80             =head2 Authentication
81              
82             When posting or replying, the username and password provided by the user are
83             passed along to the news server. If that allows the user to post, it works.
84              
85             =head2 Environment variables
86              
87             The news server is determined by L<Net::NNTP>: If no host is passed then two
88             environment variables are checked C<NNTPSERVER> then C<NEWSHOST>, then
89             L<Net::Config> is checked, and if a host is not found then C<news> is used.
90              
91             C<NEWS_INTRO_ID> can be set to a message id for a "start here" message. By
92             default, no such link is shown. This must be a message-id and cannot be a
93             message number (that would require a group, too).
94              
95             C<NEWS_MODE> can be set to "NOAUTH" in order to hide username and password on
96             the post form in case your newsserver isn't public and requires no
97             authorisation; if set to "NOPOST" then posting links are hidden.
98              
99             C<NEWS_GROUPS> can be set to a comma-separated list of patterns in the WILDMAT
100             format. The details are in RFC 3977. Usually it means: names separated by
101             commas, prefixed by C<!> if negated and C<*> used as a wildcard. Support for
102             this varies. The C<sn> server only accepts a single pattern, no negation. You
103             might have to experiment.
104              
105             =head2 Systemd
106              
107             To install as a service, use a C<news.service> file like the following:
108              
109             [Unit]
110             Description=News (a web front-end)
111             After=network-online.target
112             Wants=network-online.target
113             [Install]
114             WantedBy=multi-user.target
115             [Service]
116             Type=simple
117             DynamicUser=true
118             Restart=always
119             MemoryHigh=80M
120             MemoryMax=100M
121             Environment="NNTPSERVER=localhost"
122             Environment="NEWS_INTRO_ID=<u4d0i0$n72d$1@sibirocobombus.campaignwiki>"
123             ExecStart=/home/alex/perl5/perlbrew/perls/perl-5.32.0/bin/perl /home/alex/perl5/perlbrew/perls/perl-5.32.0/bin/news daemon
124              
125             =head2 Cookies
126              
127             The web app stores name, username and password in an encrypted cookie which
128             expires one week after posting an article.
129              
130             =head2 Caching
131              
132             The web app caches all the data it gets from the news server in a cache, using
133             L<Mojo::Cache>. By default, this cache is small (100 items). Each cached item is
134             cached with a timestamp and cache hits are only used if they aren't older than
135             5min.
136              
137             =head2 Superseding
138              
139             The web app allows superseding. It's up to the newsserver to allow or deny this.
140             There's currently no way for the user to supply their own cancel secret.
141              
142             =head1 EXAMPLES
143              
144             A remote news server.
145              
146             NNTPSERVER=cosmic.voyage news daemon
147              
148             The remote news server but only the C<campaignwiki.*> groups, with the pattern
149             in quotes to prevent shell expansion:
150              
151             NNTPSERVER=campaignwiki.org "NEWS_GROUPS=campaignwiki.*" news daemon
152              
153             The remote news server with all the groups except any C<*.test> groups, with the
154             pattern in quotes to prevent shell expansion. The C<sn> server can't parse this
155             pattern, unfortunately.
156              
157             NNTPSERVER=campaignwiki.org "NEWS_GROUPS=*,!*.test" news daemon
158              
159             The local news server requires no authorisation.
160              
161             NNTPSERVER=localhost NEWS_MODE=NOAUTH news daemon
162              
163             The news server requires authorisation and we want to point visitors to a first
164             post. We assume that NNTPSERVER or NEWSHOST is already set.
165              
166             NEWS_INTRO_ID='<u4d0i0$n72d$1@sibirocobombus.campaignwiki>' news daemon
167              
168             As a developer, run it under C<morbo> so that we can make changes to the script.
169             Provide the path to the script. This time with regular authorisation.
170              
171             PERL5LIB=lib NNTPSERVER=localhost morbo script/news
172              
173             =head1 SEE ALSO
174              
175             The Tildeverse also runs news. L<https://news.tildeverse.org/>
176              
177             L<RFC 3977|https://www.rfc-editor.org/rfc/rfc3977>: Network News Transfer
178             Protocol (NNTP).
179              
180             L<RFC 5536|https://www.rfc-editor.org/rfc/rfc5536>: Netnews Article Format.
181              
182             L<RFC 5537|https://www.rfc-editor.org/rfc/rfc5537>: Netnews Architecture and
183             Protocols.
184              
185             L<RFC 8315|https://www.rfc-editor.org/rfc/rfc8315>: Cancel-Locks in Netnews
186             Articles
187              
188             =head1 LICENSE
189              
190             GNU Affero General Public License
191              
192             =cut
193              
194             # corelist
195 4     4   4072134 use Net::NNTP;
  4         75997  
  4         421  
196 4     4   51 use Encode qw(encode decode);
  4         25  
  4         525  
197             # not core
198 4     4   2261 use Mojolicious::Lite; # Mojolicious
  4         326893  
  4         31  
199 4     4   124143 use Mojo::Cache;
  4         8  
  4         41  
200 4     4   2438 use DateTime::Format::Mail;
  4         2176000  
  4         253  
201 4     4   51 use List::Util qw(first);
  4         9  
  4         331  
202 4     4   29 use utf8;
  4         22  
  4         58  
203             # our own
204 4     4   2331 use App::news qw(wrap html_unwrap ranges);
  4         15  
  4         20623  
205              
206             my $cache = Mojo::Cache->new;
207              
208             get '/' => sub {
209             shift->redirect_to('index');
210             };
211              
212             under 'news';
213              
214             get '/' => sub {
215             my $c = shift;
216             my $list = cached("active " . ($ENV{NEWS_GROUPS} || "*"), sub {
217             my $nntp = Net::NNTP->new() or return 'error';
218             my $value = $nntp->active($ENV{NEWS_GROUPS} || "*");
219             $nntp->quit;
220             return $value });
221             return $c->render(template => 'noserver') if $list eq 'error';
222             $c->render(template => 'index', list => $list,
223             id => $ENV{NEWS_INTRO_ID},
224             address => $c->tx->req->url->to_abs->host);
225             } => 'index';
226              
227             sub cached {
228 10     10   53 my ($key, $sub) = @_;
229 10         60 my $cached = $cache->get($key);
230 10         70 my $value;
231 10 100       40 if (defined $cached) {
232 1         6 my ($ts, $data) = @$cached;
233 1         5 my $age = time - $ts;
234 1         13 app->log->debug("Cache age of $key: ${age}s");
235 1 50       54 $value = $data if $age <= 5 * 60; # cached for five minutes
236             }
237 10 100       34 if (not defined $value) {
238 9         49 app->log->debug("Getting a fresh copy of $key");
239 9         208 $value = $sub->();
240 9         109 $cache->set($key => [time, $value]);
241             }
242 10         237 return $value;
243             }
244              
245             my $per_page = 50;
246             my $per_search = 500;
247              
248             get '/group/#group' => sub {
249             my $c = shift;
250             my $group = $c->param('group');
251             my $edit = $c->param('edit');
252             my $page = $c->param('page') || "";
253             my $nntp; # only created on demand
254             my $description = cached("$group description", sub {
255             $nntp ||= Net::NNTP->new() or return 'error';
256             my $newsgroups = $nntp->newsgroups($group);
257             return $newsgroups && $newsgroups->{$group} || "" });
258             return $c->render(template => 'noserver') if 'error' eq $description;
259             my $data = cached("$group list $page", sub {
260             $nntp ||= Net::NNTP->new() or return 'error';
261             my ($nums, $first, $last) = $nntp->group($group) or return [];
262             my $last_page = int($last / $per_page) + 1;
263             $page ||= $last_page;
264             my $to = $page * $per_page;
265             $to = $last if $to > $last;
266             my $from = ($page - 1) * $per_page;
267             $from = $first if $from < $first;
268             my $fmt = $nntp->overview_fmt;
269             app->log->debug("Getting $group $from-$to");
270             my $messages = $nntp->xover("$from-$to");
271             my $articles = [];
272             for my $num (sort { $b <=> $a } keys %$messages) {
273             my ($subject, $from, $date, $id, $references) = @{$messages->{$num}};
274             $subject = decode("MIME-Header", $subject) || "?";
275             my ($tag) = $subject =~ /\[(.*?)\]/;
276             $from = no_email(decode("MIME-Header", $from));
277             $date =~ s/\s*\(.*\)//; # remove extra timezone info like "(UTC)"
278             my $dt = DateTime::Format::Mail->parse_datetime($date);
279             my $url = $c->url_for('article', group => $group, id => $num);
280             $url = $url->query(edit => $edit) if $edit;
281             push(@$articles, {
282             id => $id,
283             num => $num,
284             tag => $tag,
285             url => $url,
286             from => $from,
287             subject => $subject,
288             date => [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)],
289             references => [split(/\s+/, decode("MIME-Header", $references))],
290             replies => [] })
291             };
292             # link replies based on references but only the articles on the same pages (!)
293             for my $article (@$articles) {
294             for my $reference (@{$article->{references}}) {
295             my $original = first { $reference eq $_->{id} } @$articles;
296             next unless $original;
297             push(@{$original->{replies}}, $article->{id});
298             app->log->debug("$article->{id} is a reply to $original->{id}");
299             }
300             }
301             return {
302             articles => $articles,
303             pagination => {page => $page, last_page => $last_page}}});
304             return $c->render(template => 'noserver') if 'error' eq $data;
305             $nntp->quit if $nntp;
306             $c->render(template => 'group', group => $group, edit => $edit, description => $description,
307             list => $data->{articles}, pagination => $data->{pagination});
308             } => 'group';
309              
310             sub no_email {
311 9     9   749 my $from = shift;
312 9         49 $from =~ s/\s*<.*>//;
313 9         29 $from =~ s/\s*"\S+@\S+"//;
314 9         19 $from =~ s/\S+@\S+\s+\((.*?)\)/$1/;
315 9   50     35 return $from || "Anonymous";
316             }
317              
318             get '/tag/#group/#tag' => sub {
319             my $c = shift;
320             my $group = $c->param('group');
321             my $edit = $c->param('edit');
322             my $tag = $c->param('tag');
323             # We start counting in the back… This is different from the /group list.
324             # There, we take the first and last message numbers and compute page numbers
325             # based on that. Starting at the front makes this stable. The same articles
326             # stay on the same pages. Given first and last article numbers and a search
327             # pattern, we can't do this. Therefore, we start at the present and scan into
328             # the past until we have the page we want.
329             my $page = $c->param('page') // 0;
330             my $include = $c->param('include') // 0;
331             my $nntp; # only created on demand
332             my $data = cached("$group tag $tag", sub {
333             $nntp ||= Net::NNTP->new() or return 'error';
334             my ($nums, $first, $last) = $nntp->group($group) or return [];
335             app->log->debug("$group has $first-$last");
336             my $seen = 0; # set when we have seen $include
337             my $to = $last;
338             my $from = $to - $per_search;
339             $from = $first if $from < $first;
340             my $pattern = "*\\[$tag\\]*";
341             $pattern =~ s/ /?/g;
342             my $result = $nntp->xpat("Subject", $pattern, [$from, $to]);
343             my @nums = sort keys %$result;
344             app->log->debug("Searching pattern $pattern $from-$to found " . scalar(@nums) . " articles");
345             $seen = grep { $_ == $include } @nums if $include;
346             # keep checking more, if necessary
347             while (($page and @nums / $per_page < $page
348             or $include and not $seen)
349             and $from > $first) {
350             $to -= $per_search;
351             $from -= $per_search;
352             $from = $first if $from < $first;
353             $result = $nntp->xpat("Subject", $pattern, [$from, $to]);
354             app->log->debug("Searching pattern $pattern $from-$to found " . scalar(@nums) . " articles");
355             $seen = grep { $_ == $include } keys %$result if $include;
356             unshift(@nums, sort keys %$result);
357             }
358             # add pagination
359             if ($page) {
360             @nums = @nums[(-$page-1) * $per_page + 1, -$page * $per_page];
361             } elsif ($include) {
362             my @page;
363             while (@nums > $per_page and not grep { $_ == $include } @page) {
364             @page = splice(@nums, -$per_page);
365             $page++;
366             }
367             @nums = @page if @page;
368             }
369             my $ranges = ranges(@nums);
370             my $fmt = $nntp->overview_fmt;
371             my $re = quotemeta($tag);
372             my $articles = [];
373             for my $range (@$ranges) {
374             app->log->debug("Getting $group " . (ref $range ? join("-", @$range) : $range));
375             my $messages = $nntp->xover($range);
376             app->log->debug("Received " . scalar(keys %$messages) . " messages");
377             for my $num (sort keys %$messages) {
378             my ($subject, $from, $date, $id, $references) = @{$messages->{$num}};
379             $subject = decode("MIME-Header", $subject) || "?";
380             $subject =~ s/\[$re\]\s*//;
381             $from = no_email(decode("MIME-Header", $from));
382             $date =~ s/\s*\(.*\)//; # remove extra timezone info like "(UTC)"
383             my $dt = DateTime::Format::Mail->parse_datetime($date);
384             my $url = $c->url_for('article', group => $group, id => $num);
385             $url = $url->query(edit => $edit) if $edit;
386             push(@$articles, {
387             id => $id,
388             num => $num,
389             url => $url,
390             from => $from,
391             subject => $subject,
392             date => [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)],
393             references => [split(/\s+/, decode("MIME-Header", $references))],
394             replies => [] });
395             }
396             }
397             # link replies based on references but only the articles on the same page (!)
398             for my $article (@$articles) {
399             for my $reference (@{$article->{references}}) {
400             my $original = first { $reference eq $_->{id} } @$articles;
401             next unless $original;
402             push(@{$original->{replies}}, $article->{id});
403             app->log->debug("$article->{id} is a reply to $original->{id}");
404             }
405             }
406             # reverse the list of articles, latest ones come first
407             return [reverse @$articles]});
408             return $c->render(template => 'noserver') if 'error' eq $data;
409             $nntp->quit if $nntp;
410             # If the cached data did not include our article, delete the cache and retry.
411             # This could be optimized to extend the existing data…
412             if ($include and (@$data == 0 or $include < $data->[$#$data]->{num})) {
413             my $seen = grep { $_->{num} == $include } @$data;
414             if (not $seen) {
415             app->log->debug("$include was not seen in the cached data");
416             $cache->set("$group tag $tag" => undef);
417             return $c->redirect_to('tag');
418             }
419             }
420             $c->render(template => 'tag', group => $group, tag => $tag, edit => $edit, list => $data);
421             } => 'tag';
422              
423             # This only works for message-ids, not for message numbers (since they require a
424             # group).
425             get '/article/#id' => sub {
426             my $c = shift;
427             show_article($c, $c->param('id'));
428             } => 'article_id';
429              
430             get '/article/#group/#id' => sub {
431             my $c = shift;
432             show_article($c, $c->param('id'), $c->param('group'));
433             } => 'article';
434              
435             sub show_article {
436             # When following a link from the group, $id_or_num is a num and $group is
437             # important. When following a reference from an article, $id_or_num is a
438             # message-id and $group is only used for the reply form.
439 2     2   154 my ($c, $id_or_num, $group) = @_;
440             my $article = cached("$group article $id_or_num", sub {
441 2 50   2   27 my $nntp = Net::NNTP->new() or return 'noserver';
442 2 50       9971 $nntp->group($group) if $group;
443 2         1756 my $article = $nntp->article($id_or_num);
444 2 50       3200 return 'unknown' unless $article;
445             # app->log->trace(join("", @$article));
446             # $article is header lines, an empty line, and body lines
447 2         24 my $headers = Mojo::Headers->new;
448 2   66     54 while ($_ = shift(@$article) and /\S/) {
449 12         720 $headers->parse("$_\r\n");
450             }
451 2         124 my $id = $headers->header("message-id");
452 2   50     34 my $subject = decode("MIME-Header", $headers->header("subject")) || "?";
453 2         6939 my $from = no_email(decode("MIME-Header", $headers->header("from")));
454 2         8 my $date = $headers->header("date");
455 2         23 $date =~ s/\s*\(.*\)//; # remove extra timezone info like "(UTC)"
456 2         31 my $dt = DateTime::Format::Mail->parse_datetime($date);
457 2         2725 $date = [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)];
458 2   50     107 my $newsgroups = [split(/\s*,\s*/, decode("MIME-Header", $headers->header("newsgroups")) || "")];
459 2   33     369 $group ||= "@$newsgroups";
460 2   50     18 my $references = [split(/\s+/, decode("MIME-Header", $headers->header("references")) || "")];
461 2         63 my $body = join("", @$article);
462 2         12 $body =~ s/\s*<\S*?@\S*?>//g; # remove email addresses
463 2         9 $body =~ s/\s*"\S*?@\S*?"//g; # remove email addresses
464 2 50       15 if ($headers->header('content-type')) {
465 0         0 my ($charset) = $headers->header('content-type') =~ /charset=['"]?([^;'"]*)/;
466 0 0       0 $body = decode($charset, $body) if $charset;
467             }
468 2         41 my $value = {
469             id => $id,
470             group => $group,
471             from => $from,
472             subject => $subject,
473             date => $date,
474             newsgroups => $newsgroups,
475             references => $references,
476             html_body => html_unwrap($body),
477             body => $body,
478             };
479             # perhaps we have cached replies from looking at the group (space and no page number at the end)
480 2   100     35 my $cached_group = cached("$group list ", sub {}) || {};
481 2   100     16 my $cached_article = (first { $_->{id} eq $id } @{$cached_group->{articles}}) || {};
482 2   100     24 $value->{replies} = $cached_article->{replies} || [];
483 2         11 app->log->debug("$id replies: @{$value->{replies}}");
  2         37  
484 2         33 $nntp->quit;
485             # If $id_or_num was a number, add a second key to the cache in case we need
486             # the same article but following a reference.
487 2 50       2433 $cache->set("$group article $id" => [time, $value]) if $id_or_num ne $id;
488 2         45 return $value });
  2         127  
489 2 50       62 return $c->render(template => $article) unless ref $article;
490 2         27 $c->render(template => 'article', article => $article, edit => $c->param('edit'));
491             }
492              
493             get '/post/#group' => sub {
494             my $c = shift;
495             # copy from the cookie
496             $c->param($_ => $c->session->{$_}) for qw(name username password);
497             $c->render(template => 'post',
498             id => '',
499             subject => '',
500             supersedes => '',
501             references => '');
502             } => 'new';
503              
504             post '/reply' => sub {
505             my $c = shift;
506             # copy from the cookie
507             $c->param($_ => $c->session->{$_}) for qw(name username password);
508             $c->render(template => 'post',
509             id => $c->param('id'),
510             group => $c->param('group'),
511             subject => $c->param('subject'),
512             supersedes => '',
513             references => $c->param('references'));
514             } => 'reply';
515              
516             post '/supersede' => sub {
517             my $c = shift;
518             # copy from the cookie
519             $c->param($_ => $c->session->{$_}) for qw(name username password);
520             $c->render(template => 'post',
521             id => '',
522             body => $c->param('body'),
523             group => $c->param('group'),
524             subject => $c->param('subject'),
525             supersedes => $c->param('supersedes'),
526             references => $c->param('references'));
527             } => 'supersede';
528              
529             post '/post' => sub {
530             my $c = shift;
531             $c->session(expiration => time + 7 * 24 * 60 * 60); # one week
532             my $username = $c->param('username');
533             return $c->error("No username") unless $username or $ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOAUTH";
534             $c->session(username => $username);
535              
536             my $password = $c->param('password');
537             return $c->error("No password") unless $password or $ENV{NEWS_MODE} and $ENV{NEWS_MODE}eq "NOAUTH";
538             $c->session(password => $password);
539              
540             my $name = $c->param('name');
541             return $c->error("No from address specified") unless $name;
542             $name =~ s/[^[:graph:] ]//g;
543             return $c->error("From address does not have the format 'Your Name <mail\@example.org>'") unless $name =~ /<\S+@\S+\.\S+>/;
544             $c->session(name => $name);
545              
546             my $group = $c->param('group');
547             return $c->error("No group") unless $group;
548             $group =~ s/[^[:graph:]]//g;
549             return $c->error("No group") unless $group;
550              
551             my $references = $c->param('references');
552             my $supersedes = $c->param('supersedes');
553              
554             my $subject = $c->param('subject');
555             return $c->error("No subject") unless $subject;
556             # $subject = encode("MIME-Header", $subject);
557              
558             my $body = $c->param('body');
559             return $c->error("No body") unless $body;
560              
561             $body = wrap($body) if $c->param('wrap');
562              
563             my $nntp = Net::NNTP->new() or return $c->render(template => 'noserver');
564             $nntp->authinfo($username, $password) if $username and $password;
565             my $article = [];
566             push(@$article, "From: $name\r\n");
567             push(@$article, "Subject: $subject\r\n");
568             push(@$article, "Newsgroups: $group\r\n");
569             push(@$article, "References: $references\r\n") if $references;
570             push(@$article, "Supersedes: $supersedes\r\n") if $supersedes;
571             push(@$article, "MIME-Version: 1.0\r\n");
572             push(@$article, "Content-Type: text/plain; charset=UTF-8\r\n");
573             push(@$article, "Content-Transfer-Encoding: 8bit\r\n");
574             push(@$article, "\r\n");
575             push(@$article, map { "$_\r\n" } split(/\r?\n/, encode('UTF-8', $body)));
576             app->log->debug(join("", @$article));
577             my $ok = $nntp->post($article);
578             $cache->set("$group list " => undef) if $ok; # includes space and no page number
579             $nntp->quit;
580             $c->render('posted', group => $group, ok => $ok);
581             } => 'post';
582              
583             app->start;
584              
585             __DATA__
586              
587             @@ index.html.ep
588             % layout "default";
589             % title 'News';
590             <h1>News</h1>
591             <p>
592             This is a forum. The groups and posts it shows are from a <a
593             href="https://en.wikipedia.org/wiki/News_server">news server</a>. If you have a
594             web browser that knows how to handle news URLs, like <tt>lynx</tt>, you can
595             visit the news server <a href="news://<%= $address %>/">directly</a>.
596              
597             % if ($id) {
598             <p>
599             <%= link_to url_for('article_id', id => $id) => begin %>Start here<% end %>.
600             % }
601              
602             <table>
603             <tr><th class="status">Post</th><th>Group</th></tr>
604             % my @seen;
605             % for my $group (sort keys %$list) {
606             % my ($last, $first, $flag) = @{$list->{$group}};
607             % my $status = "";
608             % my $edit = 0;
609             % if ($flag eq "y") { $status = "OK"; $edit = 1 }
610             % elsif ($flag eq "m") { $status = "Moderated"; $edit = 1 }
611             % elsif ($flag eq "n") { $status = "Remote" }
612             % elsif ($flag eq "j") { $status = "Junked" }
613             % elsif ($flag eq "x") { $status = "Archived" }
614             % else { $status = "Renamed" }
615             % push(@seen, $flag) unless grep { $_ eq $flag } @seen;
616             % if ($edit) {
617             <tr><td class="status"><%= $status %></td><td><%= link_to url_for('group', group => $group)->fragment($last) => begin %><%= $group %><% end %><br></td></tr>
618             % } else {
619             <tr><td class="status"><%= $status %></td><td><%= link_to url_for('group', group => $group)->query(edit => 'no')->fragment($last) => begin %><%= $group %><% end %><br></td></tr>
620             % }
621             % }
622             </table>
623             <p>
624             % for my $flag (@seen) {
625             % if ($flag eq "y") {
626             OK: Posting is possible and probably requires an account.
627             % } elsif ($flag eq "m") {
628             Moderated: Posts aren't published unless approved by a moderator.
629             % } elsif ($flag eq "n") {
630             Remote: Posts from a peer are shown but you cannot post.
631             % } elsif ($flag eq "j") {
632             Junked: All posts are immediately moved to the junk group.
633             % } elsif ($flag eq "x") {
634             Archived: No new posts.
635             % } else {
636             Renamed: Posts will get moved to a different group.
637             % }
638             % }
639              
640             @@ group.html.ep
641             % layout "default";
642             % title "$group";
643             <h1><%= $group %></h1>
644             % if ($description) {
645             <p><%= $description %>
646             % }
647             <p>
648             <%= link_to url_for('index') => begin %>List all groups<% end %>
649             % if ($pagination->{page} > 1) {
650             <%= link_to url_for('group', group => $group)->query(page => 1) => begin %>First<% end %>
651             % }
652             % if ($pagination->{page} > 2) {
653             <%= link_to url_for('group', group => $group)->query(page => $pagination->{page} - 1) => begin %>Older<% end %>
654             % }
655             % if ($pagination->{page} < $pagination->{last_page} - 1) {
656             <%= link_to url_for('group', group => $group)->query(page => $pagination->{page} + 1) => begin %>Newer<% end %>
657             % }
658             % if ($pagination->{page} < $pagination->{last_page}) {
659             <%= link_to url_for('group', group => $group) => begin %>Last<% end %>
660             % }
661             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOPOST" or $edit and $edit eq "no") {
662             <%= link_to url_for('new', group => $group) => begin %>Add post<% end %> (requires account)
663             % }
664             % if (@$list) {
665             <table>
666             <tr><th class="date">Date</th><th class="from">From</th><th class="subject">Subject</th></tr>
667             % my $date = "";
668             % for my $article (@$list) {
669             % if ($article->{date}->[0] ne $date) {
670             % $date = $article->{date}->[0];
671             <tr><td class="day"><%= $date %></td><td></td><td></td></tr>
672             % }
673             % if ($article->{tag}) {
674             % my $re = quotemeta($article->{tag});
675             % my @part = split(/$re/, $article->{subject}, 2);
676             <tr><td class="time"><a href="<%= $article->{url} %>"><%= $article->{date}->[1] %></a></td><td class="from"><%= $article->{from} %></td><td class="subject"><%= $part[0] %><%= link_to url_for('tag', group => $group, tag => $article->{tag})->query(include => $article->{num}) =>begin %><%= $article->{tag} %><% end %><%= $part[1] %></td></tr>
677             % } else {
678             <tr><td class="time"><a href="<%= $article->{url} %>"><%= $article->{date}->[1] %></a></td><td class="from"><%= $article->{from} %></td><td class="subject"><%= $article->{subject} %></td></tr>
679             % }
680             % }
681             </table>
682             % } else {
683             <p>This group is empty.
684             % }
685              
686             @@ tag.html.ep
687             % layout "default";
688             % title "$group: $tag";
689             <h1><%= $group %>: <%= $tag %></h1>
690             <p>
691             <%= link_to url_for('index') => begin %>List all groups<% end %>
692             <%= link_to url_for('group', group => $group) => begin %>List all posts<% end %>
693             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOPOST" or $edit and $edit eq "no") {
694             <%= link_to url_for('new', group => $group) => begin %>Add post<% end %> (requires account)
695             % }
696             % if (@$list) {
697             <table>
698             <tr><th class="date">Date</th><th class="from">From</th><th class="subject">Subject</th></tr>
699             % my $date = "";
700             % for my $article (@$list) {
701             % if ($article->{date}->[0] ne $date) {
702             % $date = $article->{date}->[0];
703             <tr><td class="day"><%= $date %></td><td></td><td></td></tr>
704             % }
705             <tr><td class="time"><a href="<%= $article->{url} %>"><%= $article->{date}->[1] %></a></td><td class="from"><%= $article->{from} %></td><td class="subject"><%= $article->{subject} %></td></tr>
706             % }
707             </table>
708             % } else {
709             <p>This group is empty.
710             % }
711              
712             @@ article.html.ep
713             % layout "default";
714             % title "$article->{subject}";
715             <h1><%= $article->{subject} %></h1>
716             <p class="headers"><span class="value from"><%= $article->{from} %></span>,
717             <span class="date"><%= "@{$article->{date}}" %></span>,
718             % for my $newsgroup (@{$article->{newsgroups}}) {
719             <%= link_to url_for('group', group => $newsgroup) => (class => "value newsgroups") => begin %><%= $newsgroup %><% end %>
720             % }
721             % if (@{$article->{references}}) {
722             % for my $id (@{$article->{references}}) {
723             <%= link_to url_for('article', id => $id) => (class => "value references") => begin %>ref<% end %>
724             % }
725             % }
726             % if (@{$article->{references}} and @{$article->{replies}}) {
727             (this post)
728             % }
729             % if (@{$article->{replies}}) {
730             % for my $id (reverse @{$article->{replies}}) {
731             <%= link_to url_for('article', id => $id) => (class => "value replies") => begin %>reply<% end %>
732             % }
733             % }
734             <pre class="body"><%== $article->{html_body} %></pre>
735             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOPOST" or $edit and $edit eq "no") {
736             % my $subject = $article->{subject};
737             % $subject = "Re: $subject" unless $subject =~ /^Re:/i;
738             % my $body = "$article->{from}, @{$article->{date}}:\n$article->{body}";
739             % $body =~ s/\s+$//mg;
740             % $body =~ s/\n(>*) */\n>$1 /g;
741             % $body .= "\n";
742             % my @references = (@{$article->{references}}, $article->{id});
743             %= form_for reply => (class => "button") => begin
744             %= hidden_field id => $article->{id}
745             %= hidden_field group => "@{$article->{newsgroups}}"
746             %= hidden_field references => "@references"
747             %= hidden_field subject => $subject
748             %= hidden_field body => $body
749             %= submit_button 'Reply'
750             %= end
751             %= form_for supersede => (class => "button") => begin
752             %= hidden_field supersedes => $article->{id}
753             %= hidden_field group => "@{$article->{newsgroups}}"
754             %= hidden_field references => "@references"
755             %= hidden_field subject => $article->{subject}
756             %= hidden_field body => $article->{body}
757             %= submit_button 'Supersede'
758             %= end
759             (Both require an account.)
760             % }
761              
762             @@ unknown.html.ep
763             % layout "default";
764             % title "Unknown Article";
765             <h1>Unknown article</h1>
766             <p>Either the message id is wrong or the article has expired on this news
767             server.
768              
769             @@ noserver.html.ep
770             % layout "default";
771             % title "No News Server";
772             <h1>No News Server</h1>
773             <p>The administrator needs to specify the news server to use.
774             <p>One way to do this is to set the environment variable <code>NNTPSERVER</code>.
775              
776             @@ post.html.ep
777             % layout 'default';
778             % title 'Post';
779             % if ($supersedes) {
780             <h1><%= $subject %></h1>
781             <p>(This article supersedes a <%= link_to url_for('article', group => $group, id => $supersedes) => begin %>another<% end %>.)
782             % } elsif ($subject) {
783             <h1><%= $subject %></h1>
784             <p>(This is a <%= link_to url_for('article', group => $group, id => $id) => begin %>reply<% end %>.)
785             % } else {
786             <h1>New article for <%= $group %></h1>
787             % }
788             %= form_for post => begin
789             %= hidden_field group => $group
790             %= hidden_field references => $references
791             %= hidden_field supersedes => $supersedes
792             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOAUTH") {
793             %= label_for username => 'Username'
794             %= text_field 'username', required => undef
795             <br>
796             %= label_for password => 'Password'
797             %= password_field 'password', required => undef
798             <br>
799             % }
800             %= label_for name => 'From'
801             %= text_field 'name', required => undef, pattern => '.*<\S+@\S+\.\S+>', title => 'Must end with an email address in angled brackets, e.g. <you@example.org>', placeholder => 'Your Name <you@example.org>'
802             <br>
803             %= label_for subject => 'Subject'
804             %= text_field 'subject', required => undef
805             <p>
806             %= label_for body => 'Article'
807             %= text_area 'body', required => undef
808             <p>
809             %= check_box wrap => 1, checked => 1, id => 'wrap'
810             %= label_for wrap => 'Wrap'
811             %= submit_button 'Post', id => 'post'
812             % end
813              
814             @@ posted.html.ep
815             % layout 'default';
816             % title 'Posted';
817             % if ($ok) {
818             <h1>Posted!</h1>
819             <p>The article was posted to <%= link_to url_for('group', group => $group) => begin %><%= $group %><% end %>.
820             % } else {
821             <h1>Error</h1>
822             <p>Oops. Looks like posting to <%= link_to url_for('group', group => $group) => begin %><%= $group %><% end %> failed!
823             % }
824              
825             @@ layouts/default.html.ep
826             <!DOCTYPE html>
827             <html>
828             <head>
829             <title><%= title %></title>
830             %= stylesheet begin
831             body {
832             color: #111;
833             background-color: #fffff8;
834             padding: 1ch;
835             max-width: 80ch;
836             font-size: 12pt;
837             font-family: Lucida Console,Lucida Sans Typewriter,monaco,DejaVu Mono,Bitstream Vera Sans Mono,monospace;
838             hyphens: auto;
839             }
840             @media (prefers-color-scheme: dark) {
841             body {
842             color: #7f7;
843             background-color: #010;
844             }
845             a:link { color: #99f; }
846             a:visited { color: #86f; }
847             a:hover { color: #eef; }
848             }
849             .day { padding-top: 1ch; }
850             .time, .status { text-align: center; }
851             td { min-width: 10ch; padding: 0 0.5ch; }
852             label { display: inline-block; min-width: 10ch; }
853             label[for=wrap] { display: inline; }
854             input[type=password], input[type=text] { width: 30ch; }
855             textarea { width: 100%; height: 20ch; }
856             .button { display: inline-block; }
857             pre { white-space: pre-wrap; }
858             blockquote { border-left: 0.5ch solid gray; padding-left: 0.5ch; margin: 0; margin-top: 0.5ch; }
859             % end
860             <meta name="viewport" content="width=device-width">
861             </head>
862             <body lang="en">
863             <%= content %>
864             <hr>
865             <p>
866             <a href="https://campaignwiki.org/news">News</a>&#x2003;
867             <a href="https://alexschroeder.ch/cgit/news/about/">Source</a>&#x2003;
868             <a href="https://alexschroeder.ch/wiki/Contact">Alex Schroeder</a>
869             </body>
870             </html>