revamp NNTP support
[org.ibex.mail.git] / src / org / ibex / mail / NNTP.java
1 // Copyright 2000-2005 the Contributors, as shown in the revision logs.
2 // Licensed under the Apache Public Source License 2.0 ("the License").
3 // You may not use this file except in compliance with the License.
4
5 // 500 unrec. command
6 // 501 syntax error
7 // 503 optional subfeature not supported 
8 // Xref header
9 // LIST EXTENSIONS is probably incomplete
10 // pull mode (ie suck)
11 // FEATURE: control message processing?
12 //          cancel <Message-ID>      (do not forward if I am unable to cancel locally)
13 //          ihave/sendme:            do not support
14 //          newgroup <groupname> [moderated] -- body of message is a description of the group
15 //          rmgroup  <groupname>
16
17 package org.ibex.mail;
18 import org.ibex.util.*;
19 import org.ibex.io.*;
20 import org.ibex.net.*;
21 import org.ibex.mail.target.*;
22 import org.ibex.jinetd.*;
23 import java.io.*;
24 import java.net.*;
25 import java.util.*;
26 import java.text.*;
27
28 /** NNTP send/recieve */
29 public class NNTP {
30
31     public static final DateFormat serverDateFormat = new SimpleDateFormat("yyyyMMDDhhmmss");
32     public static final DateFormat shortNewNewsDateFormat = new SimpleDateFormat("yyMMDD HHMMSS");
33     public static final DateFormat longNewNewsDateFormat = new SimpleDateFormat("yyyyMMDD HHMMSS");
34     static {
35         serverDateFormat.setTimeZone(new SimpleTimeZone(SimpleTimeZone.UTC_TIME, "GMT"));
36         shortNewNewsDateFormat.setTimeZone(new SimpleTimeZone(SimpleTimeZone.UTC_TIME, "GMT"));
37         longNewNewsDateFormat.setTimeZone(new SimpleTimeZone(SimpleTimeZone.UTC_TIME, "GMT"));
38     }
39
40     public static class No  extends RuntimeException { int code = 400; }                                       // 4xx response codes
41     public static class Bad extends RuntimeException { int code = 500; public Bad(String s) { super(s); } }    // 5xx response codes
42
43     public static class Group {
44         public Group(String n, boolean p, int f, int l, int c) {
45             this.name=n;
46             this.post=p;
47             this.first=f;
48             this.last=l;
49             this.count=c;
50         }
51         public final String  name;    // case insensitive
52         public final boolean post;
53         public final int     first;
54         public final int     last;
55         public final int     count;   // an approximation; must be >= actual number
56     }
57
58     public static class Article {
59         public Article(int num, Message message) { this.message = message; this.num = num; }
60         public final int     num;
61         public final Message message;
62     }
63
64
65     /**
66      *  The API exposed by an NNTP server; remote NNTP servers appear
67      *  as instances of this class, and implementations of this class
68      *  may be exported as NNTP servers.
69      */
70     public static interface Server {
71         public Group    group(String s);
72         public boolean  ihave(String messageid);
73         public boolean  want(String messageid);
74         public Article  next();
75         public Article  last();
76         public boolean  postok();
77         public void     post(Message m);
78         public Article  article(String messageid,  boolean head, boolean body);
79         public Article  article(int    messagenum, boolean head, boolean body);
80         public Group[]  list();
81         public Group[]  newgroups(Date d);
82         public String[] newnews(String[] groups, Date d);
83     }
84
85     public static class MailboxServer implements Server {
86         private final MailTree root;
87         private Mailbox current = null;
88         private int currentMessageNumber = 0;
89         public MailboxServer(MailTree root) { this.root = root; }
90         public boolean  postok() { return true; }
91         public void     post(Message m) { current.post(m); }
92         public Group    group(String s) {
93             currentMessageNumber = 0;
94             MailTree ncurrent = resolve(s);
95             if (ncurrent == null) return null;
96             current = ncurrent.getMailbox();
97             return new Group(s, true, 1, current.count(Query.all()), current.count(Query.all()));
98         }
99         public boolean  ihave(String messageid)                   { /* FEATURE */ return false; }
100         public boolean  want(String messageid)                    { /* FEATURE */ return true; }
101         public Group[]  newgroups(Date d)                         { /* FEATURE */ return new Group[] { }; }
102         public Article  next()                                    { return article(currentMessageNumber++, false, false); }
103         public Article  last()                                    { return article(currentMessageNumber--, false, false); }
104         public String[] newnews(String[] groups, Date d) {
105             Vec ret = new Vec();
106             for(String g : groups) {
107                 Mailbox group = resolve(g).getMailbox();
108                 for(Mailbox.Iterator mit = group.iterator(Query.arrival(d, null));
109                     mit.next();) {
110                     ret.add(mit.head().get("message-id"));
111                 }
112             }
113             return (String[])ret.copyInto(new String[ret.size()]);
114         }
115         public Article  article(String i, boolean h, boolean b) { return article(Query.header("message-id",i),h,b); }
116         public Article  article(int    n, boolean h, boolean b) { currentMessageNumber = n; return article(Query.nntpNumber(n,n),h,b); }
117         private Article article(Query q,  boolean head, boolean body) {
118             Mailbox.Iterator it = current.iterator(q);
119             if (!it.next()) return null;
120             return new Article(it.nntpNumber(), body ? it.cur() : Message.newMessage(it.head()));
121         }
122         public Group[]  list() { return list(root, ""); }
123         private Group[] list(MailTree who, String prefix) {
124             Vec v = new Vec();
125             if (who == null) who = root;
126             String[] s = who.children();
127             for(int i=0; i<s.length; i++) {
128                 MailTree mtree = who.slash(s[i], false);
129
130                 // this is inefficient
131                 int low = Integer.MAX_VALUE;
132                 int high = 0;
133                 int count = 0;
134                 for(Mailbox.Iterator mit = mtree.getMailbox().iterator(); mit.next();) {
135                     count++;
136                     low = Math.min(low, mit.nntpNumber());
137                     high = Math.max(low, mit.nntpNumber());
138                 }
139                 if (count==0) { low = 1; high = 0; }
140
141                 v.addElement(new Group(prefix + s[i], true, low, high, count));
142                 Group[] g2 = list(mtree, prefix + s[i] + ".");
143                 for(int j=0; j<g2.length; j++) v.addElement(g2[j]);
144             }
145             Group[] ret = new Group[v.size()];
146             v.copyInto(ret);
147             return ret;
148         }
149         private MailTree resolve(String s) {
150             MailTree box = root;
151             for(StringTokenizer st = new StringTokenizer(s, ".");
152                 box!=null && st.hasMoreTokens();
153                 box = box.slash(st.nextToken(), false));
154             return box;
155         }
156     }
157
158     public static class Listener {
159         private Server api = null;
160         private Login login;
161         private Connection conn;
162         public Listener(Login l) { this.login = l; }
163
164         private void println(String s) { conn.println(s); }
165         private void println()         { conn.println(""); }
166         private void print(String s)   { conn.print(s); }
167
168         private void article(String numOrMessageId, boolean head, boolean body) {
169             String s = numOrMessageId.trim();
170             Article a;
171             if (s.startsWith("<")) a = api.article(s.substring(0, s.length() - 1), head, body);
172             else                   a = api.article(Integer.parseInt(s), head, body);
173             if (a == null) {
174                 println("423 No such article.");
175                 return;
176             }
177             int code = (head && body) ? 220 : head ? 221 : body ? 222 : 223;
178             println(code + " " + a.num + " <" + a.message.messageid + "> get ready for some stuff...");
179             if (head) { a.message.headers.getStream().transcribe(conn); println(); }
180             if (head && body) println();
181             if (body) {
182                 Stream stream = a.message.getBody().getStream();
183                 while(true) {
184                     s = stream.readln();
185                     if (s == null) break;
186                     if (s.startsWith(".")) print(".");
187                     println(s);
188                 }
189             }
190             println(".");
191         }
192         public void handleRequest(Connection conn) {
193             this.conn = conn;
194             conn.setTimeout(30 * 60 * 1000);
195             conn.setNewline("\r\n");
196             println("200 " + conn.vhost + " [" + NNTP.class.getName() + "]");
197             String user = null;
198             String pass = null;
199             Account account = login.anonymous();
200             this.api = account == null ? null : new MailboxServer(account.getMailbox(NNTP.class));
201             for(String line = conn.readln(); line != null; line = conn.readln()) try {
202                 Log.warn("[nntp-read]", line);
203                 StringTokenizer st = new StringTokenizer(line, " ");
204                 String command = st.nextToken().toUpperCase();
205                 if (command.equals("AUTHINFO")) {
206                     String uop = st.nextToken().toUpperCase();
207                     if (uop.equals("USER")) {
208                         user = st.nextToken();
209                         println("381 More authentication required");
210                         continue;
211                     } else if (uop.equals("PASS")) {
212                         pass = st.nextToken();
213                         account = login.login(user, pass);
214                         if (account == null) { println("502 Invalid"); continue; }
215                         this.api = new MailboxServer(account.getMailbox(NNTP.class));
216                         println("281 Good to go");
217                         continue;
218                     }
219                     throw new Bad("wtf are you talking about?");
220                 }
221                 if (this.api == null) {
222                     if (user == null) { println("480 Authentication required"); continue; }
223                     if (pass == null) { println("381 Password required"); continue; }
224                 }
225                 if        (command.equals("ARTICLE"))   { article(st.hasMoreTokens() ? st.nextToken() : null, true,  true); 
226                 } else if (command.equals("HEAD"))      { article(st.hasMoreTokens() ? st.nextToken() : null, true,  false); 
227                 } else if (command.equals("DATE"))      { println("111 " + serverDateFormat.format(new Date()));
228                 } else if (command.equals("MODE"))      {
229                     if (st.hasMoreTokens()) {
230                         String arg = st.nextToken();
231                         if (arg.equalsIgnoreCase("STREAM"));
232                         println("203 Streaming permitted");
233                     } else {
234                         println("201 Hello, you can post.");
235                     }
236                 } else if (command.equals("BODY"))      { article(st.hasMoreTokens() ? st.nextToken() : null, false, true); 
237                 } else if (command.equals("STAT"))      { article(st.hasMoreTokens() ? st.nextToken() : null, false, false); 
238                 } else if (command.equals("HELP"))      { println("100 you are beyond help."); println(".");
239                 } else if (command.equals("SLAVE"))     { println("220 SLAVE was removed in RFC3977, you should not use it");
240                 } else if (command.equals("XOVER") || command.equals("OVER"))     {
241                     println("224 Overview information follows");
242                     MailboxServer api = (MailboxServer)this.api;
243                     String range = st.hasMoreTokens() ? st.nextToken() : (api.currentMessageNumber+"-"+api.currentMessageNumber);
244                     int start = Integer.parseInt(range.substring(0, range.indexOf('-')));
245                     int end   = Integer.parseInt(range.substring(range.indexOf('-') + 1));
246                     Mailbox.Iterator it = api.current.iterator(Query.nntpNumber(start, end));
247                     while(it.next()) {
248                         try {
249                             Message m = it.cur();
250                             println(it.nntpNumber()+"\t"+m.subject+"\t"+m.from+"\t"+m.date+"\t"+m.messageid+"\t"+
251                                     m.headers.get("references") + "\t" + m.getLength() + "\t" + m.getNumLines());
252                         } catch (Exception e) { Log.error(this, e); }
253                     }
254                     println(".");
255                 } else if (command.equals("LAST"))      {
256                     Article a = api.last(); println("223 "+a.num+" "+a.message.messageid+" ok");
257                 } else if (command.equals("NEXT"))      {
258                     Article a = api.next(); println("223 "+a.num+" "+a.message.messageid+" ok");
259                 } else if (command.equals("QUIT"))      { println("205 Bye."); conn.close(); return; 
260                 } else if (command.equals("GROUP"))     {
261                     Group g = api.group(st.nextToken().toLowerCase());
262                     if (g==null) println("411 no such group");
263                     else         println("211 " + g.count + " " + g.first + " " + g.last + " " + g.name);
264                 } else if (command.equals("NEWGROUPS") || command.equals("NEWNEWS")) { 
265                     // FIXME: * and ! unsupported
266                     String groups = command.equals("NEWNEWS") ? st.nextToken() : null;
267                     String datetime = st.nextToken() + " " + st.nextToken();
268                     String gmt = st.nextToken();
269
270                     Date d = new Date();
271                     try { d = (datetime.length() == 13 ? shortNewNewsDateFormat : longNewNewsDateFormat)
272                               .parse(datetime);
273                     } catch (ParseException pe) { Log.warn(this, pe); }
274
275                     if (command.equals("NEWGROUPS")) {
276                         Group[] g = api.newgroups(d);
277                         println("231 list of groups follows");
278                         for(int i=0; i<g.length; i++)
279                             println(g[i].name + " " + g[i].last + " " + g[i].first + " " + (g[i].post ? "y" : "n"));
280                         println(".");
281                     } else {
282                         st = new StringTokenizer(groups, ",");
283                         String[] g = new String[st.countTokens()];
284                         for(int i=0; st.hasMoreTokens(); i++) g[i] = st.nextToken();
285                         String[] a = api.newnews(g, d);
286                         println("230 list of article messageids follows");
287                         for(int i=0; i<a.length; i++) println(a[i]);
288                         println(".");
289                     }
290
291                 } else if (command.equals("POST"))      { 
292                     boolean postok = api.postok();
293                     if (!postok) {
294                         println("440 no posting allowed");
295                     } else {
296                       println("340 send the article");
297                       try {
298                           Message m = Message.readDotEncodedMessage(conn);
299                           if (m.headers.get("newsgroups")==null)
300                               println("441 posted messages must have a Newsgroups header per RFC 977");
301                           else if (m.headers.get("newsgroups").indexOf('*')!=-1)
302                               println("441 Newsgroups header in posted messages may not contain wildcards (*) per RFC 977");
303                           else if (m.headers.get("subject")==null)
304                               println("441 posted messages must have a Subject header per RFC 977");
305                           else if (m.headers.get("from")==null)
306                               println("441 posted messages must have a From header per RFC 977");
307                           else if (m.headers.get("date")==null)
308                               println("441 posted messages must have a Date header per RFC 977");
309                           else {
310                               api.post(m);
311                               println("240 article posted ok");
312                           }
313                       } catch (Exception e) {
314                           e.printStackTrace();
315                           println("441 posting failed: " + e);
316                       }
317                     }
318
319                 } else if (command.equals("XROVER"))      { 
320                     // equivalent to "XHDR References"
321
322                 } else if (command.equals("XHDR") || (command.equals("HDR")))      { 
323                     // argument: header name
324                     // argument: 1 | 1- | 1-2 | <mid> | nothing (use current article)
325                     println("221 yep");
326                     // print art#+header for all matching messages
327                     println(".");
328                     // 412 if no group selected and numeric form used
329                     // 430 if <mid> and not found
330                     // 420 if no messages in range
331
332                 } else if (command.equals("XPAT"))      { 
333                     // just like XHDR, but a pattern follows the last argument (may contain whitespace)
334                     println("221 yep");
335                     // print 
336                     println(".");
337
338                 } else if (command.equals("LIST"))      { 
339                     if (st.hasMoreTokens()) {
340                         String argument = st.nextToken().toUpperCase();
341                         if (argument.equalsIgnoreCase("EXTENSIONS")) {
342                             println("202 Extensions supported:");
343                             println("STREAMING");
344                             println("");
345                             println(".");
346                         } else if (argument.equals("ACTIVE")) {
347                             String wildmat = st.hasMoreTokens() ? st.nextToken() : null;
348                             // FIXME: deal with wildmat
349                             // just like list, but only show active groups
350                             throw new Bad("not implemented yet");
351                         } else if (argument.equals("SUBSCRIPTIONS")) {
352                             // FIXME: show 215, default subscription list for new users, period
353                         } else if (argument.equals("OVERVIEW.FMT")) {
354                             println("215 Overview format:");
355                             println("Subject:");
356                             println("From:");
357                             println("Date:");
358                             println("Message-ID:");
359                             println("References:");
360                             println("Bytes:");
361                             println("Lines:");
362                             //println("Xref:full");
363                             println(".");
364                         } else if (argument.equals("NEWSGROUPS")) {
365                             String wildmat = st.hasMoreTokens() ? st.nextToken() : null;
366                             // respond 215, print each newsgroup, a space, and the description; end with lone period
367                         } else {
368                             // barf here
369                         }
370                     } else {
371                         Group[] g = api.list();
372                         println("215 list of groups follows");
373                         for(int i=0; i<g.length; i++)
374                             println(g[i].name + " " + g[i].last + " " + g[i].first + " " + (g[i].post ? "y" : "n"));
375                         println(".");
376                     }
377
378                 } else if (command.equals("LISTGROUP"))     {
379                     String groupname = st.hasMoreTokens() ? st.nextToken() : null;
380                     // 211, all article numbers in group, period.  Set article currentMessageNumber to first item in group
381
382                 } else if (command.equals("XGTITLE"))     {
383                     String wildmat = st.hasMoreTokens() ? st.nextToken() : null;
384                     // 282, then identical to LIST NEWSGROUP
385
386                 } else if (command.equals("CHECK"))     {
387                     String mid = st.nextToken();
388                     boolean want = api.want(mid);
389                     if (!want) println("438 "+ mid+" No thanks");
390                     else       println("238 "+mid+" Yes, I'd like that");
391
392                 } else if (command.equals("TAKETHIS"))     {
393                     String mid = st.nextToken();
394                     boolean want = api.want(mid);
395                     Message m = Message.readDotEncodedMessage(conn);
396                     if (want) {
397                         api.post(m);
398                         println("239 "+mid+" Rock on.");
399                     } else {
400                         println("439 "+ mid+" I really didn't want that; don't send it again.");
401                     }
402
403                 } else if (command.equals("IHAVE"))     {
404                     boolean want = api.ihave(st.nextToken());
405                     if (!want) {
406                         println("435 No thanks");
407                     } else {
408                         println("335 Proceed");
409                         api.post(Message.readDotEncodedMessage(conn));
410                         println("235 Got it");
411                     }
412                 } else {
413                     throw new Bad("wtf are you talking about?");
414                 }
415             } catch (No n)  { println(n.code + " " + n.getMessage());
416             } catch (Bad b) { println(b.code + " " + b.getMessage()); Log.warn(this, b); }
417             conn.close();
418         }
419     }
420 }