File Coverage

script/news
Criterion Covered Total %
statement 73 75 97.3
branch 11 20 55.0
condition 13 20 65.0
subroutine 11 11 100.0
pod n/a
total 108 126 85.7


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 fron-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             For each group, only the most recent posts are shown. The From field is stripped
33             of things that look like email addresses such as C<\s*<.*>> and C<\s*"\S+@\S+">.
34              
35             For each article, an attempt is made to strip email addresses, too. Detecting
36             email addresses could be better, for sure.
37              
38             =head2 Authentication
39              
40             When posting or replying, the username and password provided by the user are
41             passed along to the news server. If that allows the user to post, it just works.
42              
43             =head2 Environment variables
44              
45             The news server is determined by L<Net::NNTP>: If no host is passed then two
46             environment variables are checked C<NNTPSERVER> then C<NEWSHOST>, then
47             L<Net::Config> is checked, and if a host is not found then C<news> is used.
48              
49             C<NEWS_INTRO_ID> can be set to a message id for a "start here" message. By
50             default, no such link is shown. This must be a message-id and cannot be a
51             message number (that would require a group, too).
52              
53             C<NEWS_MODE> can be set to "NOAUTH" in order to hide username and password on
54             the post form in case your newsserver isn't public and requires no
55             authorisation; if set to "NOPOST" then posting links are hidden.
56              
57             =head2 Systemd
58              
59             To install as a service, use a C<news.service> file like the following:
60              
61             [Unit]
62             Description=News (a web front-end)
63             After=network-online.target
64             Wants=network-online.target
65             [Install]
66             WantedBy=multi-user.target
67             [Service]
68             Type=simple
69             DynamicUser=true
70             Restart=always
71             MemoryHigh=80M
72             MemoryMax=100M
73             Environment="NNTPSERVER=localhost"
74             Environment="NEWS_INTRO_ID=<u4d0i0$n72d$1@sibirocobombus.campaignwiki>"
75             ExecStart=/home/alex/perl5/perlbrew/perls/perl-5.32.0/bin/perl /home/alex/perl5/perlbrew/perls/perl-5.32.0/bin/news daemon
76              
77             =head2 Cookies
78              
79             The web app stores name, username and password in an encrypted cookie which
80             expires one week after posting an article.
81              
82             =head2 Caching
83              
84             The web app caches all the data it gets from the news server in a cache, using
85             L<Mojo::Cache>. By default, this cache is small (100 items). Each cached item is
86             cached with a timestamp and cache hits are only used if they aren't older than
87             5min.
88              
89             =head1 EXAMPLES
90              
91             The local news server requires no authorisation.
92              
93             NNTPSERVER=localhost NEWS_MODE=NOAUTH news daemon
94              
95             As a developer, run it under C<morbo> so that we can make changes to the script.
96             Provide the path to the script. This time with regular authorisation.
97              
98             NNTPSERVER=localhost morbo script/news
99              
100             The remote news server requires authorisation and we want to point visitors to a
101             first post. We assume that NNTPSERVER or NEWSHOST is set.
102              
103             NEWS_INTRO_ID='<u4d0i0$n72d$1@sibirocobombus.campaignwiki>' news daemon
104              
105             =head1 SEE ALSO
106              
107             The Tildeverse also runs news.
108             L<https://news.tildeverse.org/>
109              
110             =head1 LICENSE
111              
112             GNU Affero General Public License
113              
114             =cut
115              
116             # corelist
117 3     3   3053287 use Net::NNTP;
  3         57435  
  3         335  
118 3     3   47 use Encode qw(encode decode);
  3         7  
  3         380  
119             # not core
120 3     3   1579 use Mojolicious::Lite; # Mojolicious
  3         238873  
  3         21  
121 3     3   90646 use Mojo::Cache;
  3         9  
  3         46  
122 3     3   1694 use DateTime::Format::Mail;
  3         1626840  
  3         192  
123 3     3   46 use List::Util qw(first);
  3         10  
  3         239  
124 3     3   19 use utf8;
  3         10  
  3         34  
125              
126             my $cache = Mojo::Cache->new;
127              
128             get '/' => sub {
129             shift->redirect_to('index');
130             };
131              
132             under 'news';
133              
134             get '/' => sub {
135             my $c = shift;
136             my $list = cached("list", sub {
137             my $nntp = Net::NNTP->new() or return 'error';
138             my $value = $nntp->list();
139             $nntp->quit;
140             return $value });
141             return $c->render(template => 'noserver') if $list eq 'error';
142             $c->render(template => 'index', list => $list,
143             id => $ENV{NEWS_INTRO_ID},
144             address => $c->tx->req->url->to_abs->host);
145             } => 'index';
146              
147             sub cached {
148 9     9   68 my ($key, $sub) = @_;
149 9         62 my $cached = $cache->get($key);
150 9         51 my $value;
151 9 100       32 if (defined $cached) {
152 1         4 my ($ts, $data) = @$cached;
153 1         3 my $age = time - $ts;
154 1         5 app->log->debug("Cache age of $key: ${age}s");
155 1 50       23 $value = $data if $age <= 5 * 60; # cached for five minutes
156             }
157 9 100       24 if (not defined $value) {
158 8         46 app->log->debug("Getting a fresh copy of $key");
159 8         179 $value = $sub->();
160 8         136 $cache->set($key => [time, $value]);
161             }
162 9         196 return $value;
163             }
164              
165             my $per_page = 50;
166              
167             get '/group/#group' => sub {
168             my $c = shift;
169             my $group = $c->param('group');
170             my $edit = $c->param('edit');
171             my $page = $c->param('page') || "";
172             my $nntp; # only created on demand
173             my $description = cached("$group description", sub {
174             $nntp ||= Net::NNTP->new() or return 'error';
175             my $newsgroups = $nntp->newsgroups($group);
176             return $newsgroups && $newsgroups->{$group} || "" });
177             return $c->render(template => 'noserver') if 'error' eq $description;
178             my $data = cached("$group list $page", sub {
179             $nntp ||= Net::NNTP->new() or return 'error';
180             my ($nums, $first, $last) = $nntp->group($group) or return [];
181             my $last_page = int($last / $per_page) + 1;
182             $page ||= $last_page;
183             my $to = $page * $per_page;
184             $to = $last if $to > $last;
185             my $from = ($page - 1) * $per_page;
186             $from = $first if $from < $first;
187             my $fmt = $nntp->overview_fmt;
188             app->log->debug("Getting $group $from-$to");
189             my $messages = $nntp->xover("$from-$to");
190             my $articles = [];
191             for my $num (sort { $b <=> $a } keys %$messages) {
192             my ($subject, $from, $date, $id, $references) = @{$messages->{$num}};
193             $subject = decode("MIME-Header", $subject) || "?";
194             $from = no_email(decode("MIME-Header", $from)) || "Anonymous";
195             $date =~ s/\s*\(.*\)//; # remove extra timezone info like "(UTC)"
196             my $dt = DateTime::Format::Mail->parse_datetime($date);
197             push(@$articles, {
198             id => $id,
199             num => $num,
200             from => $from,
201             subject => $subject,
202             date => [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)],
203             references => [split(/\s+/, decode("MIME-Header", $references))],
204             replies => [] })
205             };
206             # link replies based on references but only the articles on the same pages (!)
207             for my $article (@$articles) {
208             for my $reference (@{$article->{references}}) {
209             my $original = first { $reference eq $_->{id} } @$articles;
210             next unless $original;
211             push(@{$original->{replies}}, $article->{id});
212             app->log->debug("$article->{id} is a reply to $original->{id}");
213             }
214             }
215             return {
216             articles => $articles,
217             pagination => {page => $page, last_page => $last_page}}});
218             return $c->render(template => 'noserver') if 'error' eq $data;
219             $nntp->quit if $nntp;
220             $c->render(template => 'group', group => $group, edit => $edit, description => $description,
221             list => $data->{articles}, pagination => $data->{pagination});
222             } => 'group';
223              
224             sub no_email {
225 6     6   576 my $from = shift;
226 6         48 $from =~ s/\s*<.*>//;
227 6         15 $from =~ s/\s*"\S+@\S+"//;
228 6         12 $from =~ s/\S+@\S+\s+\((.*?)\)/$1/;
229 6         39 return $from;
230             }
231              
232             # This only works for message-ids, not for message numbers (since they require a
233             # group).
234             get '/article/#id' => sub {
235             my $c = shift;
236             show_article($c, $c->param('id'));
237             } => 'article_id';
238              
239             get '/article/#group/#id' => sub {
240             my $c = shift;
241             show_article($c, $c->param('id'), $c->param('group'));
242             } => 'article';
243              
244             sub show_article {
245             # When following a link from the group, $id_or_num is a num and $group is
246             # important. When following a reference from an article, $id_or_num is a
247             # message-id and $group is only used for the reply form.
248 2     2   145 my ($c, $id_or_num, $group) = @_;
249             my $article = cached("$group article $id_or_num", sub {
250 2 50   2   22 my $nntp = Net::NNTP->new() or return 'error';
251 2 50       9810 $nntp->group($group) if $group;
252 2         2491 my $article = $nntp->article($id_or_num);
253 2 50       3588 return $c->render(template => 'unknown') unless $article;
254             # app->log->trace(join("", @$article));
255             # $article is header lines, an empty line, and body lines
256 2         16 my $headers = Mojo::Headers->new;
257 2   66     51 while ($_ = shift(@$article) and /\S/) {
258 12         687 $headers->parse("$_\r\n");
259             }
260 2         121 my $id = $headers->header("message-id");
261 2   50     28 my $subject = decode("MIME-Header", $headers->header("subject")) || "?";
262 2   50     6913 my $from = no_email(decode("MIME-Header", $headers->header("from"))) || "Anonymous";
263 2         9 my $date = $headers->header("date");
264 2         24 $date =~ s/\s*\(.*\)//; # remove extra timezone info like "(UTC)"
265 2         20 my $dt = DateTime::Format::Mail->parse_datetime($date);
266 2         2071 $date = [$dt->ymd, sprintf("%02d:%02d", $dt->hour, $dt->minute)];
267 2   50     94 my $newsgroups = [split(/\s*,\s*/, decode("MIME-Header", $headers->header("newsgroups")) || "")];
268 2   33     211 $group ||= "@$newsgroups";
269 2   50     10 my $references = [split(/\s+/, decode("MIME-Header", $headers->header("references")) || "")];
270 2         43 my $body = join("", @$article);
271 2         7 $body =~ s/\s*<\S*?@\S*?>//g; # remove email addresses
272 2         4 $body =~ s/\s*"\S*?@\S*?"//g; # remove email addresses
273 2 50       9 if ($headers->header('content-type')) {
274 0         0 my ($charset) = $headers->header('content-type') =~ /charset=['"]?([^;'"]*)/;
275 0 0       0 $body = decode($charset, $body) if $charset;
276             }
277 2         33 my $value = {
278             id => $id,
279             group => $group,
280             from => $from,
281             subject => $subject,
282             date => $date,
283             newsgroups => $newsgroups,
284             references => $references,
285             body => $body,
286             };
287             # perhaps we have cached replies from looking at the group (space and no page number at the end)
288 2   100     23 my $cached_group = cached("$group list ", sub {}) || {};
289 2   100     13 my $cached_article = (first { $_->{id} eq $id } @{$cached_group->{articles}}) || {};
290 2   100     17 $value->{replies} = $cached_article->{replies} || [];
291 2         9 app->log->debug("$id replies: @{$value->{replies}}");
  2         24  
292 2         23 $nntp->quit;
293             # If $id_or_num was a number, add a second key to the cache in case we need
294             # the same article but following a reference.
295 2 50       2787 $cache->set("$group article $id" => [time, $value]) if $id_or_num ne $id;
296 2         36 return $value });
  2         86  
297 2 50       42 return $c->render(template => 'noserver') if 'error' eq $article;
298 2         14 $c->render(template => 'article', article => $article, edit => $c->param('edit'));
299             }
300              
301             get '/post/#group' => sub {
302             my $c = shift;
303             # copy from the cookie
304             $c->param($_ => $c->session->{$_}) for qw(name username password);
305             $c->render(template => 'post',
306             id => '',
307             subject => '',
308             references => '');
309             } => 'post_group';
310              
311             post '/reply' => sub {
312             my $c = shift;
313             # copy from the cookie
314             $c->param($_ => $c->session->{$_}) for qw(name username password);
315             $c->render(template => 'post',
316             id => $c->param('id'),
317             group => $c->param('group'),
318             subject => $c->param('subject'),
319             references => $c->param('references'));
320             } => 'post_reply';
321              
322             post '/post' => sub {
323             my $c = shift;
324             $c->session(expiration => time + 7 * 24 * 60 * 60); # one week
325             my $username = $c->param('username');
326             return $c->error("No username") unless $username or $ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOAUTH";
327             $c->session(username => $username);
328              
329             my $password = $c->param('password');
330             return $c->error("No password") unless $password or $ENV{NEWS_MODE} and $ENV{NEWS_MODE}eq "NOAUTH";
331             $c->session(password => $password);
332              
333             my $name = $c->param('name');
334             return $c->error("No from address specified") unless $name;
335             $name =~ s/[^[:graph:] ]//g;
336             return $c->error("From address does not have the format 'Your Name <mail\@example.org>'") unless $name =~ /<\S+@\S+\.\S+>/;
337             $c->session(name => $name);
338              
339             my $group = $c->param('group');
340             return $c->error("No group") unless $group;
341             $group =~ s/[^[:graph:]]//g;
342             return $c->error("No group") unless $group;
343              
344             my $references = $c->param('references');
345              
346             my $subject = $c->param('subject');
347             return $c->error("No subject") unless $subject;
348             # $subject = encode("MIME-Header", $subject);
349              
350             my $body = $c->param('body');
351             return $c->error("No body") unless $body;
352              
353             my $nntp = Net::NNTP->new() or return $c->render(template => 'noserver');
354             $nntp->authinfo($username, $password) if $username and $password;
355             my $article = [];
356             push(@$article, "From: $name\r\n");
357             push(@$article, "Subject: $subject\r\n");
358             push(@$article, "Newsgroups: $group\r\n");
359             push(@$article, "References: $references\r\n") if $references;
360             push(@$article, "MIME-Version: 1.0\r\n");
361             push(@$article, "Content-Type: text/plain; charset=UTF-8\r\n");
362             push(@$article, "Content-Transfer-Encoding: 8bit\r\n");
363             push(@$article, "\r\n");
364             push(@$article, map { "$_\r\n" } split(/\r?\n/, encode('UTF-8', $body)));
365             app->log->debug(join("", @$article));
366             my $ok = $nntp->post($article);
367             $cache->set("$group list " => undef) if $ok; # includes space and no page number
368             $nntp->quit;
369             $c->render('posted', group => $group, ok => $ok);
370             } => 'post';
371              
372             app->start;
373              
374             __DATA__
375              
376             @@ index.html.ep
377             % layout "default";
378             % title 'News';
379             <h1>News</h1>
380             <p>
381             This is a forum. The groups and posts it shows are from a <a
382             href="https://en.wikipedia.org/wiki/News_server">news server</a>. If you have a
383             web browser that knows how to handle news URLs, like <tt>lynx</tt>, you can
384             visit the news server <a href="news://<%= $address %>/">directly</a>.
385              
386             % if ($id) {
387             <p>
388             <%= link_to url_for('article_id', id => $id) => begin %>Start here<% end %>.
389             % }
390              
391             <table>
392             <tr><th class="status">Post</th><th>Group</th></tr>
393             % my @seen;
394             % for my $group (sort keys %$list) {
395             % my ($last, $first, $flag) = @{$list->{$group}};
396             % my $status = "";
397             % my $edit = 0;
398             % if ($flag eq "y") { $status = "OK"; $edit = 1 }
399             % elsif ($flag eq "m") { $status = "Moderated"; $edit = 1 }
400             % elsif ($flag eq "n") { $status = "Remote" }
401             % elsif ($flag eq "j") { $status = "Junked" }
402             % elsif ($flag eq "x") { $status = "Archived" }
403             % else { $status = "Renamed" }
404             % push(@seen, $flag) unless grep { $_ eq $flag } @seen;
405             % if ($edit) {
406             <tr><td class="status"><%= $status %></td><td><%= link_to url_for('group', group => $group)->fragment($last) => begin %><%= $group %><% end %><br></td></tr>
407             % } else {
408             <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>
409             % }
410             % }
411             </table>
412             <p>
413             % for my $flag (@seen) {
414             % if ($flag eq "y") {
415             OK: Posting is possible and probably requires an account.
416             % } elsif ($flag eq "m") {
417             Moderated: Posts aren't published unless approved by a moderator.
418             % } elsif ($flag eq "n") {
419             Remote: Posts from a peer are shown but you cannot post.
420             % } elsif ($flag eq "j") {
421             Junked: All posts are immediately moved to the junk group.
422             % } elsif ($flag eq "x") {
423             Archived: No new posts.
424             % } else {
425             Renamed: Posts will get moved to a different group.
426             % }
427             % }
428              
429             @@ group.html.ep
430             % layout "default";
431             % title "$group";
432             <h1><%= $group %></h1>
433             % if ($description) {
434             <p><%= $description %>
435             % }
436             <p>
437             <%= link_to url_for('index') => begin %>List all groups<% end %>
438             % if ($pagination->{page} > 1) {
439             <%= link_to url_for('group', group => $group)->query(page => 1) => begin %>First<% end %>
440             % }
441             % if ($pagination->{page} > 2) {
442             <%= link_to url_for('group', group => $group)->query(page => $pagination->{page} - 1) => begin %>Older<% end %>
443             % }
444             % if ($pagination->{page} < $pagination->{last_page} - 1) {
445             <%= link_to url_for('group', group => $group)->query(page => $pagination->{page} + 1) => begin %>Newer<% end %>
446             % }
447             % if ($pagination->{page} < $pagination->{last_page}) {
448             <%= link_to url_for('group', group => $group) => begin %>Last<% end %>
449             % }
450             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOPOST" or $edit and $edit eq "no") {
451             <%= link_to url_for('post_group', group => $group) => begin %>Add post<% end %> (requires account)
452             % }
453             % if (@$list) {
454             <table>
455             <tr><th class="date">Date</th><th class="from">From</th><th class="subject">Subject</th></tr>
456             % my $date = "";
457             % for my $article (@$list) {
458             % if ($article->{date}->[0] ne $date) {
459             % $date = $article->{date}->[0];
460             <tr><td class="day"><%= $date %></td><td></td><td></td></tr>
461             % }
462             % if ($edit and $edit eq "no") {
463             <tr><td class="time"><%= link_to url_for('article', group => $group, id => $article->{num})->query(edit => $edit) => begin %><%= $article->{date}->[1] %><% end %></td><td class="from"><%= $article->{from} %></td><td class="subject"><%= $article->{subject} %></td></tr>
464             % } else {
465             <tr><td class="time"><%= link_to url_for('article', group => $group, id => $article->{num}) => begin %><%= $article->{date}->[1] %><% end %></td><td class="from"><%= $article->{from} %></td><td class="subject"><%= $article->{subject} %></td></tr>
466             % }
467             % }
468             </table>
469             % } else {
470             <p>This group is empty.
471             % }
472              
473             @@ article.html.ep
474             % layout "default";
475             % title "$article->{subject}";
476             <h1><%= $article->{subject} %></h1>
477             <p class="headers"><span class="value from"><%= $article->{from} %></span>,
478             <span class="date"><%= "@{$article->{date}}" %></span>,
479             % for my $newsgroup (@{$article->{newsgroups}}) {
480             <%= link_to url_for('group', group => $newsgroup) => (class => "value newsgroups") => begin %><%= $newsgroup %><% end %>
481             % }
482             % if (@{$article->{references}}) {
483             % for my $id (@{$article->{references}}) {
484             <%= link_to url_for('article', id => $id) => (class => "value references") => begin %>ref<% end %>
485             % }
486             % }
487             % if (@{$article->{references}} and @{$article->{replies}}) {
488             (this post)
489             % }
490             % if (@{$article->{replies}}) {
491             % for my $id (reverse @{$article->{replies}}) {
492             <%= link_to url_for('article', id => $id) => (class => "value replies") => begin %>reply<% end %>
493             % }
494             % }
495             <pre class="body"><%= $article->{body} %></pre>
496             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOPOST" or $edit and $edit eq "no") {
497             % my $subject = $article->{subject};
498             % $subject = "Re: $subject" unless $subject =~ /^Re:/i;
499             % my $body = "$article->{from}, @{$article->{date}}:\n$article->{body}";
500             % $body =~ s/\s+$//mg;
501             % $body =~ s/\n(>*) */\n>$1 /g;
502             % $body .= "\n";
503             % my @references = (@{$article->{references}}, $article->{id});
504             %= form_for post_reply => begin
505             %= hidden_field id => $article->{id}
506             %= hidden_field group => "@{$article->{newsgroups}}"
507             %= hidden_field references => "@references"
508             %= hidden_field subject => $subject
509             %= hidden_field body => $body
510             %= submit_button 'Reply'
511             (Requires account.)
512             %= end
513             % }
514              
515             @@ unknown.html.ep
516             % layout "default";
517             % title "Unknown Article";
518             <h1>Unknown article</h1>
519             <p>Either the message id is wrong or the article has expired on this news
520             server.
521              
522             @@ noserver.html.ep
523             % layout "default";
524             % title "No News Server";
525             <h1>No News Server</h1>
526             <p>The administrator needs to specify the news server to use.
527             <p>One way to do this is to set the environment variable <code>NNTPSERVER</code>.
528              
529             @@ post.html.ep
530             % layout 'default';
531             % title 'Post';
532             % if ($subject) {
533             <h1><%= $subject %></h1>
534             <p>(This is a <%= link_to url_for('article', group => $group, id => $id) => begin %>reply<% end %>.)
535             % } else {
536             <h1>New post for <%= $group %></h1>
537             % }
538             %= form_for post => begin
539             %= hidden_field group => $group
540             %= hidden_field references => $references
541             % unless ($ENV{NEWS_MODE} and $ENV{NEWS_MODE} eq "NOAUTH") {
542             %= label_for username => 'Username'
543             %= text_field 'username', required => undef
544             <br>
545             %= label_for password => 'Password'
546             %= password_field 'password', required => undef
547             <br>
548             % }
549             %= label_for name => 'From'
550             %= 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>'
551             <br>
552             %= label_for subject => 'Subject'
553             %= text_field 'subject', required => undef
554             <p>
555             %= label_for body => 'Article'
556             %= text_area 'body', required => undef
557             <p>
558             %= submit_button 'Post'
559             % end
560              
561             @@ posted.html.ep
562             % layout 'default';
563             % title 'Posted';
564             % if ($ok) {
565             <h1>Posted!</h1>
566             <p>The article was posted to <%= link_to url_for('group', group => $group) => begin %><%= $group %><% end %>.
567             % } else {
568             <h1>Error</h1>
569             <p>Oops. Looks like posting to <%= link_to url_for('group', group => $group) => begin %><%= $group %><% end %> failed!
570             % }
571              
572             @@ layouts/default.html.ep
573             <!DOCTYPE html>
574             <html>
575             <head>
576             <title><%= title %></title>
577             %= stylesheet begin
578             body {
579             color: #111;
580             background-color: #fffff8;
581             padding: 1ch;
582             max-width: 80ch;
583             font-size: 12pt;
584             font-family: Lucida Console,Lucida Sans Typewriter,monaco,DejaVu Mono,Bitstream Vera Sans Mono,monospace;
585             hyphens: auto;
586             }
587             @media (prefers-color-scheme: dark) {
588             body {
589             color: #7f7;
590             background-color: #010;
591             }
592             a:link { color: #99f; }
593             a:visited { color: #86f; }
594             a:hover { color: #eef; }
595             }
596             .day { padding-top: 1ch; }
597             .time, .status { text-align: center; }
598             td { min-width: 10ch; padding: 0 0.5ch; }
599             label { display: inline-block; min-width: 10ch; }
600             input[type=password], input[type=text] { width: 30ch; }
601             textarea { width: 100%; height: 20ch; }
602             pre { white-space: pre-wrap; }
603             % end
604             <meta name="viewport" content="width=device-width">
605             </head>
606             <body lang="en">
607             <%= content %>
608             <hr>
609             <p>
610             <a href="https://campaignwiki.org/news">News</a>&#x2003;
611             <a href="https://alexschroeder.ch/cgit/news/about/">Source</a>&#x2003;
612             <a href="https://alexschroeder.ch/wiki/Contact">Alex Schroeder</a>
613             </body>
614             </html>