Commit Diff


commit - f8aabde3b59dde0bd91fb293b5a4e4860c63226c
commit + 3f2ad062778268029067033bd88826837cd3d7dd
blob - 32e47df45d5a504f9960679933f6a37c1dc4aeae
blob + eb4d4fab472f6ed4cb15febb9528b41a2b01b467
--- .gitignore
+++ .gitignore
@@ -1,3 +1,4 @@
 *~
 #*
 /examples/client/client
+/examples/server/server
blob - /dev/null
blob + 72818dce6215b9a38c7ee655eb7d24f8beb74b7a (mode 644)
--- /dev/null
+++ examples/server/exampleserver.go
@@ -0,0 +1,265 @@
+package main
+
+import (
+	"bytes"
+	"container/ring"
+	"io"
+	"log"
+	"net"
+	"net/textproto"
+	"strconv"
+	"strings"
+
+	"github.com/dustin/go-nntp"
+	"github.com/dustin/go-nntp/server"
+)
+
+const maxArticles = 100
+
+type articleRef struct {
+	msgid string
+	num   int64
+}
+
+type groupStorage struct {
+	group *nntp.Group
+	// article refs
+	articles *ring.Ring
+}
+
+type articleStorage struct {
+	headers  textproto.MIMEHeader
+	body     string
+	refcount int
+}
+
+type testBackendType struct {
+	// group name -> group storage
+	groups map[string]*groupStorage
+	// message ID -> article
+	articles map[string]*articleStorage
+}
+
+var testBackend = testBackendType{
+	groups:   make(map[string]*groupStorage),
+	articles: make(map[string]*articleStorage),
+}
+
+func init() {
+
+	testBackend.groups["alt.test"] = &groupStorage{
+		group: &nntp.Group{"alt.test", "A test.",
+			0, 0, 0, nntp.PostingNotPermitted},
+		articles: ring.New(maxArticles),
+	}
+
+	testBackend.groups["misc.test"] = &groupStorage{
+		group: &nntp.Group{"misc.test", "More testing.",
+			0, 0, 0, nntp.PostingPermitted},
+		articles: ring.New(maxArticles),
+	}
+
+}
+
+func (tb *testBackendType) ListGroups(max int) ([]*nntp.Group, error) {
+	rv := make([]*nntp.Group, 0, 100)
+	for _, g := range tb.groups {
+		rv = append(rv, g.group)
+	}
+	return rv, nil
+}
+
+func (tb *testBackendType) GetGroup(name string) (*nntp.Group, error) {
+	var group *nntp.Group
+
+	for _, g := range tb.groups {
+		if g.group.Name == name {
+			group = g.group
+			break
+		}
+	}
+
+	if group == nil {
+		return nil, nntpserver.NoSuchGroup
+	}
+
+	return group, nil
+}
+
+func mkArticle(a *articleStorage) *nntp.Article {
+	return &nntp.Article{
+		Header: a.headers,
+		Body:   strings.NewReader(a.body),
+		Bytes:  len(a.body),
+		Lines:  strings.Count(a.body, "\n"),
+	}
+}
+
+func findInRing(in *ring.Ring, f func(r interface{}) bool) *ring.Ring {
+	if f(in.Value) {
+		return in
+	}
+	for p := in.Next(); p != in; p = p.Next() {
+		if f(p.Value) {
+			return p
+		}
+	}
+	return nil
+}
+
+func (tb *testBackendType) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) {
+
+	msgId := id
+	var a *articleStorage
+
+	if intid, err := strconv.ParseInt(id, 10, 64); err == nil {
+		msgId = ""
+		// by int ID.  Gotta go find it.
+		if groupStorage, ok := tb.groups[group.Name]; ok {
+			r := findInRing(groupStorage.articles, func(v interface{}) bool {
+				if v != nil {
+					log.Printf("Looking at %v", v)
+				}
+				if aref, ok := v.(articleRef); ok && aref.num == intid {
+					return true
+				}
+				return false
+			})
+			if aref, ok := r.Value.(articleRef); ok {
+				msgId = aref.msgid
+			}
+		}
+	}
+
+	a = tb.articles[msgId]
+	if a == nil {
+		return nil, nntpserver.InvalidMessageId
+	}
+
+	return mkArticle(a), nil
+}
+
+func (tb *testBackendType) GetArticles(group *nntp.Group,
+	from, to int64) ([]nntpserver.NumberedArticle, error) {
+
+	gs, ok := tb.groups[group.Name]
+	if !ok {
+		return nil, nntpserver.NoSuchGroup
+	}
+
+	log.Printf("Getting articles from %d to %d", from, to)
+
+	rv := make([]nntpserver.NumberedArticle, 0, maxArticles)
+	gs.articles.Do(func(v interface{}) {
+		if v != nil {
+			if aref, ok := v.(articleRef); ok {
+				if aref.num >= from && aref.num <= to {
+					a, ok := tb.articles[aref.msgid]
+					if ok {
+						article := mkArticle(a)
+						rv = append(rv, nntpserver.NumberedArticle{aref.num, article})
+					}
+				}
+			}
+		}
+	})
+	return rv, nil
+}
+
+func (tb *testBackendType) AllowPost() bool {
+	return true
+}
+
+func (tb *testBackendType) decr(msgid string) {
+	if a, ok := tb.articles[msgid]; ok {
+		a.refcount--
+		if a.refcount == 0 {
+			log.Printf("Getting rid of %v", msgid)
+			delete(tb.articles, msgid)
+		}
+	}
+}
+
+func (tb *testBackendType) Post(article *nntp.Article) error {
+	log.Printf("Got headers: %#v", article.Header)
+	b := []byte{}
+	buf := bytes.NewBuffer(b)
+	n, err := io.Copy(buf, article.Body)
+	if err != nil {
+		return err
+	}
+	log.Printf("Read %d bytes of body", n)
+
+	a := articleStorage{
+		headers:  article.Header,
+		body:     buf.String(),
+		refcount: 0,
+	}
+
+	msgId := a.headers.Get("Message-Id")
+
+	if _, ok := tb.articles[msgId]; ok {
+		return nntpserver.PostingFailed
+	}
+
+	for _, g := range article.Header["Newsgroups"] {
+		if g, ok := tb.groups[g]; ok {
+			g.articles = g.articles.Next()
+			if g.articles.Value != nil {
+				aref := g.articles.Value.(articleRef)
+				tb.decr(aref.msgid)
+			}
+			if g.articles.Value != nil || g.group.Low == 0 {
+				g.group.Low++
+			}
+			g.group.High++
+			g.articles.Value = articleRef{
+				msgId,
+				g.group.High,
+			}
+			log.Printf("Placed %v", g.articles.Value)
+			a.refcount++
+			g.group.Count = int64(g.articles.Len())
+
+			log.Printf("Stored %v in %v", msgId, g.group.Name)
+		}
+	}
+
+	if a.refcount > 0 {
+		tb.articles[msgId] = &a
+	} else {
+		return nntpserver.PostingFailed
+	}
+
+	return nil
+}
+
+func (tb *testBackendType) Authorized() bool {
+	return true
+}
+
+func (tb *testBackendType) Authenticate(user, pass string) error {
+	return nntpserver.AuthRejected
+}
+
+func maybefatal(err error, f string, a ...interface{}) {
+	if err != nil {
+		log.Fatalf(f, a...)
+	}
+}
+
+func main() {
+	a, err := net.ResolveTCPAddr("tcp", ":1119")
+	maybefatal(err, "Error resolving listener: %v", err)
+	l, err := net.ListenTCP("tcp", a)
+	maybefatal(err, "Error setting up listener: %v", err)
+	defer l.Close()
+
+	s := nntpserver.NewServer(&testBackend)
+
+	for {
+		c, err := l.AcceptTCP()
+		maybefatal(err, "Error accepting connection: %v", err)
+		go s.Process(c)
+	}
+}
blob - d8ec5e9ca77aa52080bf3c80ed7f36a5a6ecbae8
blob + 6526b96e2fb854dd3897584f9b4b96c9cc92edce
--- nntp.go
+++ nntp.go
@@ -1,5 +1,11 @@
 package nntp
 
+import (
+	"fmt"
+	"io"
+	"net/textproto"
+)
+
 type PostingStatus byte
 
 const (
@@ -9,10 +15,26 @@ const (
 	PostingModerated    = PostingStatus('m')
 )
 
+func (ps PostingStatus) String() string {
+	return fmt.Sprintf("%c", ps)
+}
+
 type Group struct {
-	Name    string
-	Count   int64
-	High    int64
-	Low     int64
-	Posting PostingStatus
+	Name        string
+	Description string
+	Count       int64
+	High        int64
+	Low         int64
+	Posting     PostingStatus
 }
+
+type Article struct {
+	Header textproto.MIMEHeader
+	Body   io.Reader
+	Bytes  int
+	Lines  int
+}
+
+func (a *Article) MessageId() string {
+	return a.Header.Get("Message-Id")
+}
blob - /dev/null
blob + 131f148a4571f0deff0cd0630d5ce57a6998c227 (mode 644)
--- /dev/null
+++ server/server.go
@@ -0,0 +1,506 @@
+package nntpserver
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"math"
+	"net"
+	"net/textproto"
+	"strconv"
+	"strings"
+
+	"github.com/dustin/go-nntp"
+)
+
+type NNTPError struct {
+	Code int
+	Msg  string
+}
+
+var NoSuchGroup = &NNTPError{411, "No such newsgroup"}
+
+var NoGroupSelected = &NNTPError{412, "No newsgroup selected"}
+
+var InvalidMessageId = &NNTPError{430, "No article with that message-id"}
+var InvalidArticleNumber = &NNTPError{423, "No article with that number"}
+var NoCurrentArticle = &NNTPError{420, "Current article number is invalid"}
+
+var UnknownCommand = &NNTPError{500, "Unknown command"}
+var SyntaxError = &NNTPError{501, "not supported, or syntax error"}
+
+var PostingNotPermitted = &NNTPError{440, "Posting not permitted"}
+var PostingFailed = &NNTPError{441, "posting failed"}
+var NotWanted = &NNTPError{435, "Article not wanted"}
+
+var AuthRequired = &NNTPError{450, "authorization required"}
+var AuthRejected = &NNTPError{452, "authorization rejected"}
+
+// Low-level protocol handler
+type Handler func(args []string, s *Server, c *textproto.Conn) error
+
+type NumberedArticle struct {
+	Num     int64
+	Article *nntp.Article
+}
+
+// The backend that provides the things and does the stuff.
+type Backend interface {
+	ListGroups(max int) ([]*nntp.Group, error)
+	GetGroup(name string) (*nntp.Group, error)
+	GetArticle(group *nntp.Group, id string) (*nntp.Article, error)
+	GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error)
+	Authorized() bool
+	Authenticate(user, pass string) error
+	AllowPost() bool
+	Post(article *nntp.Article) error
+}
+
+type Server struct {
+	Handlers map[string]Handler
+	Backend  Backend
+	group    *nntp.Group
+}
+
+func NewServer(backend Backend) *Server {
+	rv := Server{
+		Handlers: make(map[string]Handler),
+		Backend:  backend,
+	}
+	rv.Handlers[""] = handleDefault
+	rv.Handlers["quit"] = handleQuit
+	rv.Handlers["group"] = handleGroup
+	rv.Handlers["list"] = handleList
+	rv.Handlers["head"] = handleHead
+	rv.Handlers["body"] = handleBody
+	rv.Handlers["article"] = handleArticle
+	rv.Handlers["post"] = handlePost
+	rv.Handlers["ihave"] = handleIHave
+	rv.Handlers["capabilities"] = handleCap
+	rv.Handlers["mode"] = handleMode
+	rv.Handlers["authinfo"] = handleAuthInfo
+	rv.Handlers["newgroups"] = handleNewGroups
+	rv.Handlers["over"] = handleOver
+	rv.Handlers["xover"] = handleOver
+	return &rv
+}
+
+func (e *NNTPError) Error() string {
+	return fmt.Sprintf("%d %s", e.Code, e.Msg)
+}
+
+func (s *Server) dispatchCommand(cmd string, args []string,
+	c *textproto.Conn) (err error) {
+
+	handler, found := s.Handlers[strings.ToLower(cmd)]
+	if !found {
+		handler, found = s.Handlers[""]
+		if !found {
+			panic("No default handler.")
+		}
+	}
+	return handler(args, s, c)
+}
+
+func (s *Server) Process(tc *net.TCPConn) {
+	defer tc.Close()
+	c := textproto.NewConn(tc)
+
+	c.PrintfLine("200 Hello!")
+	for {
+		l, err := c.ReadLine()
+		if err != nil {
+			log.Printf("Error reading from client, dropping conn: %v", err)
+			return
+		}
+		cmd := strings.Split(l, " ")
+		log.Printf("Got cmd:  %+v", cmd)
+		args := []string{}
+		if len(cmd) > 1 {
+			args = cmd[1:]
+		}
+		err = s.dispatchCommand(cmd[0], args, c)
+		if err != nil {
+			_, isNNTPError := err.(*NNTPError)
+			switch {
+			case err == io.EOF:
+				// Drop this connection silently. They hung up
+				return
+			case isNNTPError:
+				c.PrintfLine(err.Error())
+			default:
+				log.Printf("Error dispatching command, dropping conn: %v",
+					err)
+				return
+			}
+		}
+	}
+}
+
+func parseRange(spec string) (low, high int64) {
+	if spec == "" {
+		return 0, math.MaxInt64
+	}
+	parts := strings.Split(spec, "-")
+	if len(parts) == 1 {
+		h, err := strconv.ParseInt(parts[0], 10, 64)
+		if err != nil {
+			h = math.MaxInt64
+		}
+		return 0, h
+	}
+	l, _ := strconv.ParseInt(parts[0], 10, 64)
+	h, err := strconv.ParseInt(parts[1], 10, 64)
+	if err != nil {
+		h = math.MaxInt64
+	}
+	return l, h
+}
+
+/*
+   "0" or article number (see below)
+   Subject header content
+   From header content
+   Date header content
+   Message-ID header content
+   References header content
+   :bytes metadata item
+   :lines metadata item
+*/
+
+func handleOver(args []string, s *Server, c *textproto.Conn) error {
+	if s.group == nil {
+		return NoGroupSelected
+	}
+	from, to := s.group.Low, s.group.High
+	articles, err := s.Backend.GetArticles(s.group, from, to)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("224 here it comes")
+	dw := c.DotWriter()
+	defer dw.Close()
+	for _, a := range articles {
+		fmt.Fprintf(dw, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", a.Num,
+			a.Article.Header.Get("Subject"),
+			a.Article.Header.Get("From"),
+			a.Article.Header.Get("Date"),
+			a.Article.Header.Get("Message-Id"),
+			a.Article.Header.Get("References"),
+			a.Article.Bytes, a.Article.Lines)
+	}
+	return nil
+}
+
+func handleList(args []string, s *Server, c *textproto.Conn) error {
+	c.PrintfLine("215 list of newsgroups follows")
+
+	ltype := "active"
+	if len(args) > 0 {
+		ltype = strings.ToLower(args[0])
+	}
+
+	dw := c.DotWriter()
+	defer dw.Close()
+
+	if ltype == "overview.fmt" {
+		fmt.Fprintln(dw, `Subject:
+From:
+Date:
+Message-ID:
+References:
+:bytes
+:lines`)
+	}
+
+	groups, err := s.Backend.ListGroups(-1)
+	if err != nil {
+		return err
+	}
+	for _, g := range groups {
+		switch ltype {
+		case "active":
+			fmt.Fprintf(dw, "%s %d %d %v\r\n",
+				g.Name, g.High, g.Low, g.Posting)
+		case "newsgroups":
+			fmt.Fprintf(dw, "%s %s\r\n", g.Name, g.Description)
+		}
+	}
+
+	return nil
+}
+
+func handleNewGroups(args []string, s *Server, c *textproto.Conn) error {
+	c.PrintfLine("231 list of newsgroups follows")
+	c.PrintfLine(".")
+	return nil
+}
+
+func handleDefault(args []string, s *Server, c *textproto.Conn) error {
+	return UnknownCommand
+}
+
+func handleQuit(args []string, s *Server, c *textproto.Conn) error {
+	c.PrintfLine("205 bye")
+	return io.EOF
+}
+
+func handleGroup(args []string, s *Server, c *textproto.Conn) error {
+	if len(args) < 1 {
+		return NoSuchGroup
+	}
+
+	group, err := s.Backend.GetGroup(args[0])
+	if err != nil {
+		return err
+	}
+
+	s.group = group
+
+	c.PrintfLine("211 %d %d %d %s",
+		group.Count, group.Low, group.High, group.Name)
+	return nil
+}
+
+func (s *Server) getArticle(args []string) (*nntp.Article, error) {
+	if s.group == nil {
+		return nil, NoGroupSelected
+	}
+	return s.Backend.GetArticle(s.group, args[0])
+}
+
+/*
+   Syntax
+     HEAD message-id
+     HEAD number
+     HEAD
+
+
+   First form (message-id specified)
+     221 0|n message-id    Headers follow (multi-line)
+     430                   No article with that message-id
+
+   Second form (article number specified)
+     221 n message-id      Headers follow (multi-line)
+     412                   No newsgroup selected
+     423                   No article with that number
+
+   Third form (current article number used)
+     221 n message-id      Headers follow (multi-line)
+     412                   No newsgroup selected
+     420                   Current article number is invalid
+*/
+
+func handleHead(args []string, s *Server, c *textproto.Conn) error {
+	article, err := s.getArticle(args)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("221 1 %s", article.MessageId())
+	dw := c.DotWriter()
+	defer dw.Close()
+	for k, v := range article.Header {
+		fmt.Fprintf(dw, "%s: %s\r\n", k, v[0])
+	}
+	return nil
+}
+
+/*
+   Syntax
+     BODY message-id
+     BODY number
+     BODY
+
+   Responses
+
+   First form (message-id specified)
+     222 0|n message-id    Body follows (multi-line)
+     430                   No article with that message-id
+
+   Second form (article number specified)
+     222 n message-id      Body follows (multi-line)
+     412                   No newsgroup selected
+     423                   No article with that number
+
+   Third form (current article number used)
+     222 n message-id      Body follows (multi-line)
+     412                   No newsgroup selected
+     420                   Current article number is invalid
+
+   Parameters
+     number        Requested article number
+     n             Returned article number
+     message-id    Article message-id
+*/
+
+func handleBody(args []string, s *Server, c *textproto.Conn) error {
+	article, err := s.getArticle(args)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("222 1 %s", article.MessageId())
+	dw := c.DotWriter()
+	defer dw.Close()
+	_, err = io.Copy(dw, article.Body)
+	return err
+}
+
+/*
+   Syntax
+     ARTICLE message-id
+     ARTICLE number
+     ARTICLE
+
+   Responses
+
+   First form (message-id specified)
+     220 0|n message-id    Article follows (multi-line)
+     430                   No article with that message-id
+
+   Second form (article number specified)
+     220 n message-id      Article follows (multi-line)
+     412                   No newsgroup selected
+     423                   No article with that number
+
+   Third form (current article number used)
+     220 n message-id      Article follows (multi-line)
+     412                   No newsgroup selected
+     420                   Current article number is invalid
+
+   Parameters
+     number        Requested article number
+     n             Returned article number
+     message-id    Article message-id
+*/
+
+func handleArticle(args []string, s *Server, c *textproto.Conn) error {
+	article, err := s.getArticle(args)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("220 1 %s", article.MessageId())
+	dw := c.DotWriter()
+	defer dw.Close()
+
+	for k, v := range article.Header {
+		fmt.Fprintf(dw, "%s: %s\r\n", k, v[0])
+	}
+
+	fmt.Fprintln(dw, "")
+
+	_, err = io.Copy(dw, article.Body)
+	return err
+}
+
+/*
+   Syntax
+     POST
+
+   Responses
+
+   Initial responses
+     340    Send article to be posted
+     440    Posting not permitted
+
+   Subsequent responses
+     240    Article received OK
+     441    Posting failed
+*/
+
+func handlePost(args []string, s *Server, c *textproto.Conn) error {
+	if !s.Backend.AllowPost() {
+		return PostingNotPermitted
+	}
+
+	c.PrintfLine("340 Go ahead")
+	var err error
+	var article nntp.Article
+	article.Header, err = c.ReadMIMEHeader()
+	if err != nil {
+		return PostingFailed
+	}
+	article.Body = c.DotReader()
+	err = s.Backend.Post(&article)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("240 article received OK")
+	return nil
+}
+
+func handleIHave(args []string, s *Server, c *textproto.Conn) error {
+	if !s.Backend.AllowPost() {
+		return NotWanted
+	}
+
+	// XXX:  See if we have it.
+	article, err := s.Backend.GetArticle(nil, args[0])
+	if article != nil {
+		return NotWanted
+	}
+
+	c.PrintfLine("335 send it")
+	article = new(nntp.Article)
+	article.Header, err = c.ReadMIMEHeader()
+	if err != nil {
+		return PostingFailed
+	}
+	article.Body = c.DotReader()
+	err = s.Backend.Post(article)
+	if err != nil {
+		return err
+	}
+	c.PrintfLine("235 article received OK")
+	return nil
+}
+
+func handleCap(args []string, s *Server, c *textproto.Conn) error {
+	c.PrintfLine("101 Capability list:")
+	dw := c.DotWriter()
+	defer dw.Close()
+
+	fmt.Fprintf(dw, "VERSION 2\n")
+	fmt.Fprintf(dw, "READER\n")
+	if s.Backend.AllowPost() {
+		fmt.Fprintf(dw, "POST\n")
+		fmt.Fprintf(dw, "IHAVE\n")
+	}
+	fmt.Fprintf(dw, "OVER\n")
+	fmt.Fprintf(dw, "XOVER\n")
+	fmt.Fprintf(dw, "LIST ACTIVE NEWSGROUPS OVERVIEW.FMT\n")
+	return nil
+}
+
+func handleMode(args []string, s *Server, c *textproto.Conn) error {
+	if s.Backend.AllowPost() {
+		c.PrintfLine("200 Posting allowed")
+	} else {
+		c.PrintfLine("201 Posting prohibited")
+	}
+	return nil
+}
+
+func handleAuthInfo(args []string, s *Server, c *textproto.Conn) error {
+	if len(args) < 2 {
+		return SyntaxError
+	}
+	if strings.ToLower(args[0]) != "user" {
+		return SyntaxError
+	}
+
+	if s.Backend.Authorized() {
+		return c.PrintfLine("250 authenticated")
+	}
+
+	c.PrintfLine("350 Continue")
+	a, err := c.ReadLine()
+	parts := strings.SplitN(a, " ", 3)
+	if strings.ToLower(parts[0]) != "authinfo" || strings.ToLower(parts[1]) != "pass" {
+		return SyntaxError
+	}
+	err = s.Backend.Authenticate(args[1], parts[2])
+	if err == nil {
+		c.PrintfLine("250 authenticated")
+	}
+	return err
+}
blob - /dev/null
blob + 0ac3f95b4f57e79a6fe7e366c8d9e77bca803a3d (mode 644)
--- /dev/null
+++ server/server_test.go
@@ -0,0 +1,32 @@
+package nntpserver
+
+import (
+	"math"
+	"testing"
+)
+
+type rangeExpectation struct {
+	input string
+	low   int64
+	high  int64
+}
+
+var rangeExpectations = []rangeExpectation{
+	rangeExpectation{"", 0, math.MaxInt64},
+	rangeExpectation{"73-", 73, math.MaxInt64},
+	rangeExpectation{"73-1845", 73, 1845},
+}
+
+func TestRangeEmpty(t *testing.T) {
+	for _, e := range rangeExpectations {
+		l, h := parseRange(e.input)
+		if l != e.low {
+			t.Fatalf("Error parsing %q, got low=%v, wanted %v",
+				e.input, l, e.low)
+		}
+		if h != e.high {
+			t.Fatalf("Error parsing %q, got high=%v, wanted %v",
+				e.input, h, e.high)
+		}
+	}
+}