]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/NNTP.pm
nntp: use long response API for LISTGROUP
[public-inbox.git] / lib / PublicInbox / NNTP.pm
1 # Copyright (C) 2015 all contributors <meta@public-inbox.org>
2 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
3 package PublicInbox::NNTP;
4 use strict;
5 use warnings;
6 use base qw(Danga::Socket);
7 use fields qw(nntpd article ng long_res);
8 use PublicInbox::Msgmap;
9 use PublicInbox::GitCatFile;
10 use PublicInbox::MID qw(mid2path);
11 use Email::Simple;
12 use Data::Dumper qw(Dumper);
13 use POSIX qw(strftime);
14 use Time::HiRes qw(gettimeofday tv_interval ualarm);
15 use constant {
16         r501 => '501 command syntax error',
17         long_response_limit => 0xffffffff,
18 };
19
20 my @OVERVIEW = qw(Subject From Date Message-ID References Bytes Lines);
21 my %OVERVIEW = map { $_ => 1 } @OVERVIEW;
22
23 # disable commands with easy DoS potential:
24 # LISTGROUP could get pretty bad, too...
25 my %DISABLED; # = map { $_ => 1 } qw(xover list_overview_fmt newnews xhdr);
26
27 sub new {
28         my ($class, $sock, $nntpd) = @_;
29         my $self = fields::new($class);
30         $self->SUPER::new($sock);
31         $self->{nntpd} = $nntpd;
32         res($self, '201 server ready - post via email');
33         $self->watch_read(1);
34         $self;
35 }
36
37 # returns 1 if we can continue, 0 if not due to buffered writes or disconnect
38 sub process_line {
39         my ($self, $l) = @_;
40         my ($req, @args) = split(/\s+/, $l);
41         $req = lc($req);
42         $req = eval {
43                 no strict 'refs';
44                 $req = $DISABLED{$req} ? undef : *{'cmd_'.$req}{CODE};
45         };
46         return res($self, '500 command not recognized') unless $req;
47
48         my $res = eval { $req->($self, @args) };
49         my $err = $@;
50         if ($err && !$self->{closed}) {
51                 chomp($l = Dumper(\$l));
52                 warning('error from: ', $l, ' ', $err);
53                 $res = '503 program fault - command not performed';
54         }
55         return 0 unless defined $res;
56         res($self, $res);
57 }
58
59 sub cmd_mode {
60         my ($self, $arg) = @_;
61         return r501 unless defined $arg;
62         $arg = uc $arg;
63         return r501 unless $arg eq 'READER';
64         '200 reader status acknowledged';
65 }
66
67 sub cmd_slave {
68         my ($self, @x) = @_;
69         return r501 if @x;
70         '202 slave status noted';
71 }
72
73 sub cmd_xgtitle {
74         my ($self, $wildmat) = @_;
75         more($self, '282 list of groups and descriptions follows');
76         list_newsgroups($self, $wildmat);
77         '.'
78 }
79
80 sub list_overview_fmt {
81         my ($self) = @_;
82         more($self, $_ . ':') foreach @OVERVIEW;
83 }
84
85 sub list_active {
86         my ($self, $wildmat) = @_;
87         wildmat2re($wildmat);
88         foreach my $ng (values %{$self->{nntpd}->{groups}}) {
89                 $ng->{name} =~ $wildmat or next;
90                 group_line($self, $ng);
91         }
92 }
93
94 sub list_active_times {
95         my ($self, $wildmat) = @_;
96         wildmat2re($wildmat);
97         foreach my $ng (values %{$self->{nntpd}->{groups}}) {
98                 $ng->{name} =~ $wildmat or next;
99                 my $c = eval { $ng->mm->created_at } || time;
100                 more($self, "$ng->{name} $c $ng->{address}");
101         }
102 }
103
104 sub list_newsgroups {
105         my ($self, $wildmat) = @_;
106         wildmat2re($wildmat);
107         foreach my $ng (values %{$self->{nntpd}->{groups}}) {
108                 $ng->{name} =~ $wildmat or next;
109                 my $d = $ng->description;
110                 more($self, "$ng->{name} $d");
111         }
112 }
113
114 # LIST SUBSCRIPTIONS not supported
115 sub cmd_list {
116         my ($self, $arg, $wildmat, @x) = @_;
117         if (defined $arg) {
118                 $arg = lc $arg;
119                 $arg =~ tr/./_/;
120                 $arg = "list_$arg";
121                 return '503 function not performed' if $DISABLED{$arg};
122                 $arg = eval {
123                         no strict 'refs';
124                         *{$arg}{CODE};
125                 };
126                 return r501 unless $arg;
127                 more($self, '215 information follows');
128                 $arg->($self, $wildmat, @x);
129         } else {
130                 more($self, '215 list of newsgroups follows');
131                 foreach my $ng (values %{$self->{nntpd}->{groups}}) {
132                         group_line($self, $ng);
133                 }
134         }
135         '.'
136 }
137
138 sub cmd_listgroup {
139         my ($self, $group) = @_;
140         if (defined $group) {
141                 my $res = cmd_group($self, $group);
142                 return $res if ($res !~ /\A211 /);
143                 more($self, $res);
144         }
145
146         $self->{ng} or return '412 no newsgroup selected';
147         $self->long_response(0, long_response_limit, sub {
148                 my ($i) = @_;
149                 my $nr = $self->{ng}->mm->id_batch($$i, sub {
150                         my ($ary) = @_;
151                         more($self, join("\r\n", @$ary));
152                 });
153
154                 # -1 to adjust for implicit increment in long_response
155                 $$i = $nr ? $$i + $nr - 1 : long_response_limit;
156         });
157 }
158
159 sub parse_time {
160         my ($date, $time, $gmt) = @_;
161         use Time::Local qw();
162         my ($YY, $MM, $DD) = unpack('A2A2A2', $date);
163         my ($hh, $mm, $ss) = unpack('A2A2A2', $time);
164         if (defined $gmt) {
165                 $gmt =~ /\A(?:UTC|GMT)\z/i or die "GM invalid: $gmt\n";
166                 $gmt = 1;
167         }
168         my @now = $gmt ? gmtime : localtime;
169         if ($YY > strftime('%y', @now)) {
170                 my $cur_year = $now[5] + 1900;
171                 $YY += int($cur_year / 1000) * 1000 - 100;
172         }
173
174         if ($gmt) {
175                 Time::Local::timegm($ss, $mm, $hh, $DD, $MM - 1, $YY);
176         } else {
177                 Time::Local::timelocal($ss, $mm, $hh, $DD, $MM - 1, $YY);
178         }
179 }
180
181 sub group_line {
182         my ($self, $ng) = @_;
183         my ($min, $max) = $ng->mm->minmax;
184         more($self, "$ng->{name} $max $min n") if defined $min && defined $max;
185 }
186
187 sub cmd_newgroups {
188         my ($self, $date, $time, $gmt, $dists) = @_;
189         my $ts = eval { parse_time($date, $time, $gmt) };
190         return r501 if $@;
191
192         # TODO dists
193         more($self, '231 list of new newsgroups follows');
194         foreach my $ng (values %{$self->{nntpd}->{groups}}) {
195                 my $c = eval { $ng->mm->created_at } || 0;
196                 next unless $c > $ts;
197                 group_line($self, $ng);
198         }
199         '.'
200 }
201
202 sub wildmat2re {
203         return $_[0] = qr/.*/ if (!defined $_[0] || $_[0] eq '*');
204         my %keep;
205         my $salt = rand;
206         use Digest::SHA qw(sha1_hex);
207         my $tmp = $_[0];
208
209         $tmp =~ s#(?<!\\)\[(.+)(?<!\\)\]#
210                 my $orig = $1;
211                 my $key = sha1_hex($orig . $salt);
212                 $orig =~ s/([^\w\-])+/\Q$1/g;
213                 $keep{$key} = $orig;
214                 $key
215                 #gex;
216         my %map = ('*' => '.*', '?' => '.' );
217         $tmp =~ s#(?<!\\)([^\w\\])#$map{$1} || "\Q$1"#ge;
218         if (scalar %keep) {
219                 $tmp =~ s#([a-f0-9]{40})#
220                         my $orig = $keep{$1};
221                         defined $orig ? $orig : $1;
222                         #ge;
223         }
224         $_[0] = qr/\A$tmp\z/;
225 }
226
227 sub ngpat2re {
228         return $_[0] = qr/\A\z/ unless defined $_[0];
229         my %map = ('*' => '.*', ',' => '|');
230         $_[0] =~ s!(.)!$map{$1} || "\Q$1"!ge;
231         $_[0] = qr/\A(?:$_[0])\z/;
232 }
233
234 sub cmd_newnews {
235         my ($self, $newsgroups, $date, $time, $gmt, $dists) = @_;
236         my $ts = eval { parse_time($date, $time, $gmt) };
237         return r501 if $@;
238         more($self, '230 list of new articles by message-id follows');
239         my ($keep, $skip) = split('!', $newsgroups, 2);
240         ngpat2re($keep);
241         ngpat2re($skip);
242         my @srch;
243         foreach my $ng (values %{$self->{nntpd}->{groups}}) {
244                 $ng->{name} =~ $keep or next;
245                 $ng->{name} =~ $skip and next;
246                 my $srch = $ng->search or next;
247                 push @srch, $srch;
248         };
249         return '.' unless @srch;
250
251         $ts .= '..';
252         my $opts = { asc => 1, limit => 1000, offset => 0 };
253         $self->long_response(0, long_response_limit, sub {
254                 my ($i) = @_;
255                 my $srch = $srch[0];
256                 my $res = $srch->query($ts, $opts);
257                 my $msgs = $res->{msgs};
258                 if (my $nr = scalar @$msgs) {
259                         more($self, '<' .
260                                 join(">\r\n<", map { $_->mid } @$msgs ).
261                                 '>');
262                         $opts->{offset} += $nr;
263                 } else {
264                         shift @srch;
265                         if (@srch) { # continue onto next newsgroup
266                                 $opts->{offset} = 0;
267                         } else { # break out of the long response.
268                                 $$i = long_response_limit;
269                         }
270                 }
271         });
272 }
273
274 sub cmd_group {
275         my ($self, $group) = @_;
276         my $no_such = '411 no such news group';
277         my $ng = $self->{nntpd}->{groups}->{$group} or return $no_such;
278
279         $self->{ng} = $ng;
280         my ($min, $max) = $ng->mm->minmax;
281         $min ||= 0;
282         $max ||= 0;
283         $self->{article} = $min;
284         my $est_size = $max - $min;
285         "211 $est_size $min $max $group";
286 }
287
288 sub article_adj {
289         my ($self, $off) = @_;
290         my $ng = $self->{ng} or return '412 no newsgroup selected';
291
292         my $n = $self->{article};
293         defined $n or return '420 no current article has been selected';
294
295         $n += $off;
296         my $mid = $ng->mm->mid_for($n);
297         unless ($mid) {
298                 $n = $off > 0 ? 'next' : 'previous';
299                 return "421 no $n article in this group";
300         }
301         $self->{article} = $n;
302         "223 $n <$mid> article retrieved - request text separately";
303 }
304
305 sub cmd_next { article_adj($_[0], 1) }
306 sub cmd_last { article_adj($_[0], -1) }
307
308 # We want to encourage using email and CC-ing everybody involved to avoid
309 # the single-point-of-failure a single server provides.
310 sub cmd_post {
311         my ($self) = @_;
312         my $ng = $self->{ng};
313         $ng ? "440 mailto:$ng->{address} to post" : '440 posting not allowed'
314 }
315
316 sub cmd_quit {
317         my ($self) = @_;
318         res($self, '205 closing connection - goodbye!');
319         $self->close;
320         undef;
321 }
322
323 sub art_lookup {
324         my ($self, $art, $set_headers) = @_;
325         my $ng = $self->{ng} or return '412 no newsgroup has been selected';
326         my ($n, $mid);
327         my $err;
328         if (defined $art) {
329                 if ($art =~ /\A\d+\z/o) {
330                         $err = '423 no such article number in this group';
331                         $n = int($art);
332                         goto find_mid;
333                 } elsif ($art =~ /\A<([^>]+)>\z/) {
334                         $err = '430 no such article found';
335                         $mid = $1;
336                         $n = $ng->mm->num_for($mid);
337                         defined $mid or return $err;
338                 } else {
339                         return r501;
340                 }
341         } else {
342                 $err = '420 no current article has been selected';
343                 $n = $self->{article};
344                 defined $n or return $err;
345 find_mid:
346                 $mid = $ng->mm->mid_for($n);
347                 defined $mid or return $err;
348         }
349
350         my $o = 'HEAD:' . mid2path($mid);
351         my $s = eval { Email::Simple->new($ng->gcf->cat_file($o)) };
352         return $err unless $s;
353         if ($set_headers) {
354                 $s->header_set('Newsgroups', $ng->{name});
355                 $s->header_set('Lines', $s->body =~ tr!\n!\n!);
356                 $s->header_set('Xref', "$ng->{domain} $ng->{name}:$n");
357
358                 # must be last
359                 if ($set_headers == 2) {
360                         $s->header_set('Bytes', bytes::length($s->as_string));
361                         $s->body_set('');
362                 }
363         }
364         [ $n, $mid, $s ];
365 }
366
367 sub simple_body_write {
368         my ($self, $s) = @_;
369         my $body = $s->body;
370         $s->body_set('');
371         $body =~ s/^\./../smg;
372         do_more($self, $body);
373         '.'
374 }
375
376 sub header_str {
377         my ($s) = @_;
378         my $h = $s->header_obj;
379         $h->header_set('Bytes');
380         $h->as_string
381 }
382
383 sub cmd_article {
384         my ($self, $art) = @_;
385         my $r = $self->art_lookup($art, 1);
386         return $r unless ref $r;
387         my ($n, $mid, $s) = @$r;
388         more($self, "220 $n <$mid> article retrieved - head and body follow");
389         do_more($self, header_str($s));
390         do_more($self, "\r\n");
391         simple_body_write($self, $s);
392 }
393
394 sub cmd_head {
395         my ($self, $art) = @_;
396         my $r = $self->art_lookup($art, 2);
397         return $r unless ref $r;
398         my ($n, $mid, $s) = @$r;
399         more($self, "221 $n <$mid> article retrieved - head follows");
400         do_more($self, header_str($s));
401         '.'
402 }
403
404 sub cmd_body {
405         my ($self, $art) = @_;
406         my $r = $self->art_lookup($art, 0);
407         return $r unless ref $r;
408         my ($n, $mid, $s) = @$r;
409         more($self, "222 $n <$mid> article retrieved - body follows");
410         simple_body_write($self, $s);
411 }
412
413 sub cmd_stat {
414         my ($self, $art) = @_;
415         my $r = $self->art_lookup($art, 0);
416         return $r unless ref $r;
417         my ($n, $mid, undef) = @$r;
418         "223 $n <$mid> article retrieved - request text separately";
419 }
420
421 sub cmd_ihave { '435 article not wanted - do not send it' }
422
423 sub cmd_date { '111 '.strftime('%Y%m%d%H%M%S', gmtime(time)) }
424
425 sub cmd_help {
426         my ($self) = @_;
427         more($self, '100 help text follows');
428         '.'
429 }
430
431 sub get_range {
432         my ($self, $range) = @_;
433         my $ng = $self->{ng} or return '412 no news group has been selected';
434         defined $range or return '420 No article(s) selected';
435         my ($beg, $end);
436         my ($min, $max) = $ng->mm->minmax;
437         if ($range =~ /\A(\d+)\z/) {
438                 $beg = $end = $1;
439         } elsif ($range =~ /\A(\d+)-\z/) {
440                 ($beg, $end) = ($1, $max);
441         } elsif ($range =~ /\A(\d+)-(\d+)\z/) {
442                 ($beg, $end) = ($1, $2);
443         } else {
444                 return r501;
445         }
446         $beg = $min if ($beg < $min);
447         $end = $max if ($end > $max);
448         return '420 No article(s) selected' if ($beg > $end);
449         [ $beg, $end ];
450 }
451
452 sub xhdr {
453         my ($r, $header) = @_;
454         $r = $r->[2]->header_obj->header($header);
455         defined $r or return;
456         $r =~ s/[\r\n\t]+/ /sg;
457         $r;
458 }
459
460 sub long_response {
461         my ($self, $beg, $end, $cb) = @_;
462         die "BUG: nested long response" if $self->{long_res};
463
464         # make sure we disable reading during a long response,
465         # clients should not be sending us stuff and making us do more
466         # work while we are stream a response to them
467         $self->watch_read(0);
468         $self->{long_res} = sub {
469                 # limit our own running time for fairness with other
470                 # clients and to avoid buffering too much:
471                 my $yield;
472                 local $SIG{ALRM} = sub { $yield = 1 };
473                 ualarm(100000);
474
475                 my $err;
476                 do {
477                         eval { $cb->(\$beg) };
478                 } until (($err = $@) || $self->{closed} || $yield ||
479                          $self->{write_buf_size} || ++$beg > $end);
480                 ualarm(0);
481
482                 if ($err || $self->{closed}) {
483                         $self->{long_res} = undef;
484                         warning("$err during long response") if $err;
485                         $self->watch_read(1) unless $self->{closed};
486                 } elsif ($yield || $self->{write_buf_size}) {
487                         # no recursion, schedule another call ASAP
488                         # but only after all pending writes are done
489                         Danga::Socket->AddTimer(0, sub {
490                                 $self->write($self->{long_res});
491                         });
492                 } else { # all done!
493                         $self->{long_res} = undef;
494                         $self->watch_read(1);
495                         res($self, '.');
496                 }
497         };
498         $self->{long_res}->(); # kick off!
499         undef;
500 }
501
502 sub cmd_xhdr {
503         my ($self, $header, $range) = @_;
504         defined $self->{ng} or return '412 no news group currently selected';
505         unless (defined $range) {
506                 defined($range = $self->{article}) or
507                         return '420 no current article has been selected';
508         }
509         if ($range =~ /\A<(.+)>\z/) { # Message-ID
510                 my $r = $self->art_lookup($range, 2);
511                 return $r unless ref $r;
512                 more($self, '221 Header follows');
513                 if (defined($r = xhdr($r, $header))) {
514                         more($self, "<$range> $r");
515                 }
516                 '.';
517         } else { # numeric range
518                 my $r = get_range($self, $range);
519                 return $r unless ref $r;
520                 my ($beg, $end) = @$r;
521                 more($self, '221 Header follows');
522                 $self->long_response($beg, $end, sub {
523                         my ($i) = @_;
524                         $r = $self->art_lookup($$i, 2);
525                         return unless ref $r;
526                         defined($r = xhdr($r, $header)) or return;
527                         more($self, "$$i $r");
528                 });
529         }
530 }
531
532 sub cmd_xover {
533         my ($self, $range) = @_;
534         my $r = get_range($self, $range);
535         return $r unless ref $r;
536         my ($beg, $end) = @$r;
537         more($self, "224 Overview information follows for $beg to $end");
538         $self->long_response($beg, $end, sub {
539                 my ($i) = @_;
540                 my $r = $self->art_lookup($$i, 2);
541                 return unless ref $r;
542                 more($self, join("\t", $r->[0],
543                                 map {
544                                         my $h = xhdr($r, $_);
545                                         defined $h ? $h : '';
546                                 } @OVERVIEW ));
547         });
548 }
549
550 sub res {
551         my ($self, $line) = @_;
552         do_write($self, $line . "\r\n");
553 }
554
555 sub more {
556         my ($self, $line) = @_;
557         do_more($self, $line . "\r\n");
558 }
559
560 sub do_write {
561         my ($self, $data) = @_;
562         my $done = $self->write($data);
563         die if $self->{closed};
564
565         # Do not watch for readability if we have data in the queue,
566         # instead re-enable watching for readability when we can
567         $self->watch_read(0) if (!$done || $self->{long_res});
568
569         $done;
570 }
571
572 use constant MSG_MORE => ($^O eq 'linux') ? 0x8000 : 0;
573
574 sub do_more {
575         my ($self, $data) = @_;
576         if (MSG_MORE && !$self->{write_buf_size}) {
577                 my $n = send($self->{sock}, $data, MSG_MORE);
578                 if (defined $n) {
579                         my $dlen = bytes::length($data);
580                         return 1 if $n == $dlen; # all done!
581                         $data = bytes::substr($data, $n, $dlen - $n);
582                 }
583         }
584         $self->do_write($data);
585 }
586
587 # callbacks for by Danga::Socket
588
589 sub event_hup { $_[0]->close }
590 sub event_err { $_[0]->close }
591
592 sub event_write {
593         my ($self) = @_;
594         # only continue watching for readability when we are done writing:
595         $self->write(undef) == 1 and $self->watch_read(1);
596 }
597
598 sub event_read {
599         my ($self) = @_;
600         use constant LINE_MAX => 512; # RFC 977 section 2.3
601         my $r = 1;
602         my $buf = $self->read(LINE_MAX) or return $self->close;
603         while ($r > 0 && $$buf =~ s/\A([^\r\n]+)\r?\n//) {
604                 my $line = $1;
605                 my $t0 = [ gettimeofday ];
606                 $r = eval { $self->process_line($line) };
607                 printf(STDERR "$line %0.6f\n",
608                         tv_interval($t0, [gettimeofday]));
609         }
610         return $self->close if $r < 0;
611         my $len = bytes::length($$buf);
612         return $self->close if ($len >= LINE_MAX);
613         $self->push_back_read($buf) if ($len);
614 }
615
616 sub warning { print STDERR @_, "\n" }
617
618 1;