├── .gitignore ├── go.mod ├── README.markdown ├── examples ├── couchserver │ ├── encoder_test.go │ ├── lazyopen.go │ ├── groupcreator │ │ └── creator.go │ ├── views.go │ └── couchserver.go ├── client │ └── exampleclient.go └── server │ └── exampleserver.go ├── go.sum ├── .github └── workflows │ └── go.yml ├── server ├── server_test.go └── server.go ├── LICENSE ├── nntp.go └── client └── client.go /.gitignore: -------------------------------------------------------------------------------- 1 | #* 2 | *~ 3 | /examples/client/client 4 | /examples/couchserver/couchserver 5 | /examples/couchserver/groupcreator/groupcreator 6 | /examples/server/server 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dustin/go-nntp 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/dustin/go-couch v0.0.0-20160816170231-8251128dab73 7 | github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # go nntp 2 | 3 | I needed a way to gate some web services into traditional readers. I 4 | wrote an NNTP client and server. 5 | 6 | I'm still working on coming up with the exact right interfaces, but 7 | take a look at [the couchserver][couchserver] example to see what it 8 | takes to build a custom NNTP server with your own backend. 9 | 10 | [couchserver]: examples/couchserver/couchserver.go 11 | -------------------------------------------------------------------------------- /examples/couchserver/encoder_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestJSONMarshalling(t *testing.T) { 9 | a := attachment{ 10 | "application/octet-stream", 11 | []byte("some bytes"), 12 | } 13 | b, err := json.Marshal(&a) 14 | if err != nil { 15 | t.Fatalf("Error marshalling attachment: %v", err) 16 | } 17 | t.Logf("Marshalled to %v", string(b)) 18 | } 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/dustin/go-couch v0.0.0-20160816170231-8251128dab73 h1:YKyWSyEhJ3DYKgSpjOXpQgpxD3N+1EfIanJZj1ZEhpM= 2 | github.com/dustin/go-couch v0.0.0-20160816170231-8251128dab73/go.mod h1:WG/TWzFd/MRvOZ4jjna3FQ+K8AKhb2jOw4S2JMw9VKI= 3 | github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89 h1:A740DRjmFFdm3+GeYVfs4QN/QMOAbMw8KdsZMDhUCjQ= 4 | github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0= 5 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.16 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package nntpserver 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | ) 7 | 8 | type rangeExpectation struct { 9 | input string 10 | low int64 11 | high int64 12 | } 13 | 14 | var rangeExpectations = []rangeExpectation{ 15 | rangeExpectation{"", 0, math.MaxInt64}, 16 | rangeExpectation{"73-", 73, math.MaxInt64}, 17 | rangeExpectation{"73-1845", 73, 1845}, 18 | } 19 | 20 | func TestRangeEmpty(t *testing.T) { 21 | for _, e := range rangeExpectations { 22 | l, h := parseRange(e.input) 23 | if l != e.low { 24 | t.Fatalf("Error parsing %q, got low=%v, wanted %v", 25 | e.input, l, e.low) 26 | } 27 | if h != e.high { 28 | t.Fatalf("Error parsing %q, got high=%v, wanted %v", 29 | e.input, h, e.high) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/couchserver/lazyopen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | type lazyOpener struct { 11 | url string 12 | data []byte 13 | err error 14 | } 15 | 16 | func (l *lazyOpener) init() { 17 | res, err := http.Get(l.url) 18 | l.err = err 19 | if err == nil { 20 | defer res.Body.Close() 21 | if res.StatusCode == 200 { 22 | l.data, l.err = ioutil.ReadAll(res.Body) 23 | } else { 24 | l.err = errors.New(res.Status) 25 | } 26 | } 27 | } 28 | 29 | func (l *lazyOpener) Read(p []byte) (n int, err error) { 30 | if l.data == nil && l.err == nil { 31 | l.init() 32 | } 33 | if l.err != nil { 34 | return 0, err 35 | } 36 | if len(l.data) == 0 { 37 | return 0, io.EOF 38 | } 39 | copied := copy(p, l.data) 40 | l.data = l.data[copied:] 41 | return copied, nil 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2014 Dustin Sallings 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | 22 | -------------------------------------------------------------------------------- /nntp.go: -------------------------------------------------------------------------------- 1 | // Package nntp provides base NNTP definitions. 2 | package nntp 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "net/textproto" 8 | ) 9 | 10 | // PostingStatus type for groups. 11 | type PostingStatus byte 12 | 13 | // PostingStatus values. 14 | const ( 15 | Unknown = PostingStatus(0) 16 | PostingPermitted = PostingStatus('y') 17 | PostingNotPermitted = PostingStatus('n') 18 | PostingModerated = PostingStatus('m') 19 | ) 20 | 21 | func (ps PostingStatus) String() string { 22 | return fmt.Sprintf("%c", ps) 23 | } 24 | 25 | // Group represents a usenet newsgroup. 26 | type Group struct { 27 | Name string 28 | Description string 29 | Count int64 30 | High int64 31 | Low int64 32 | Posting PostingStatus 33 | } 34 | 35 | // An Article that may appear in one or more groups. 36 | type Article struct { 37 | // The article's headers 38 | Header textproto.MIMEHeader 39 | // The article's body 40 | Body io.Reader 41 | // Number of bytes in the article body (used by OVER/XOVER) 42 | Bytes int 43 | // Number of lines in the article body (used by OVER/XOVER) 44 | Lines int 45 | } 46 | 47 | // MessageID provides convenient access to the article's Message ID. 48 | func (a *Article) MessageID() string { 49 | return a.Header.Get("Message-Id") 50 | } 51 | -------------------------------------------------------------------------------- /examples/couchserver/groupcreator/creator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "io" 7 | "log" 8 | "os" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/dustin/go-couch" 13 | ) 14 | 15 | var wg sync.WaitGroup 16 | 17 | type agroup struct { 18 | Type string `json:"type"` 19 | Name string `json:"_id"` 20 | Description string `json:"description"` 21 | } 22 | 23 | func process(db *couch.Database, line string) { 24 | defer wg.Done() 25 | parts := strings.SplitN(strings.TrimSpace(line), " ", 2) 26 | if len(parts) != 2 { 27 | log.Printf("Error parsing %v", line) 28 | return 29 | } 30 | log.Printf("Processing %#v", parts) 31 | g := agroup{ 32 | Type: "group", 33 | Name: parts[0], 34 | Description: parts[1], 35 | } 36 | _, _, err := db.Insert(g) 37 | if err != nil { 38 | log.Printf("Error saving %#v: %v", g, err) 39 | } 40 | } 41 | 42 | func main() { 43 | 44 | couchURL := flag.String("couch", "http://localhost:5984/news", 45 | "Couch DB.") 46 | flag.Parse() 47 | 48 | db, err := couch.Connect(*couchURL) 49 | if err != nil { 50 | log.Fatalf("Can't connect to couch: %v", err) 51 | } 52 | 53 | br := bufio.NewReader(os.Stdin) 54 | for { 55 | line, err := br.ReadString('\n') 56 | if err == io.EOF { 57 | break 58 | } 59 | if err != nil { 60 | log.Fatalf("Error reading line: %v", err) 61 | } 62 | 63 | wg.Add(1) 64 | go process(&db, line) 65 | } 66 | 67 | wg.Wait() 68 | } 69 | -------------------------------------------------------------------------------- /examples/client/exampleclient.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/dustin/go-nntp/client" 11 | ) 12 | 13 | const examplepost = `From: 14 | Newsgroups: misc.test 15 | Subject: Code test 16 | Organization: spy internetworking 17 | 18 | This is a test post. 19 | ` 20 | 21 | func maybefatal(s string, e error) { 22 | if e != nil { 23 | log.Fatalf("Error in %s: %v", s, e) 24 | } 25 | } 26 | 27 | func main() { 28 | server, user, pass := os.Args[1], os.Args[2], os.Args[3] 29 | c, err := nntpclient.New("tcp", server) 30 | maybefatal("connecting", err) 31 | defer c.Close() 32 | log.Printf("Got banner: %v", c.Banner) 33 | 34 | // Authenticate 35 | msg, err := c.Authenticate(user, pass) 36 | maybefatal("authenticating", err) 37 | log.Printf("Post authentication message: %v", msg) 38 | 39 | // Set the reader mode 40 | _, _, err = c.Command("mode reader", 2) 41 | maybefatal("setting reader mode", err) 42 | 43 | // Select a group 44 | g, err := c.Group("misc.test") 45 | maybefatal("grouping", err) 46 | log.Printf("Got %#v", g) 47 | 48 | // List the gruop 49 | n, id, r, err := c.Head(strconv.FormatInt(g.High-1, 10)) 50 | maybefatal("getting head", err) 51 | log.Printf("msg %d has id %v and the following headers", n, id) 52 | _, err = io.Copy(os.Stdout, r) 53 | maybefatal("reading head", err) 54 | 55 | // Get an article body 56 | n, id, r, err = c.Body(strconv.FormatInt(n, 10)) 57 | maybefatal("getting body", err) 58 | log.Printf("Body of message %v", id) 59 | io.Copy(os.Stdout, r) 60 | maybefatal("reading body", err) 61 | 62 | // Get a full article 63 | n, id, r, err = c.Article(strconv.FormatInt(n, 10)) 64 | maybefatal("getting the whole thing", err) 65 | log.Printf("Full message %v", id) 66 | io.Copy(os.Stdout, r) 67 | maybefatal("reading the full message", err) 68 | 69 | // Post an article 70 | err = c.Post(strings.NewReader(examplepost)) 71 | maybefatal("posting", err) 72 | log.Printf("Posted!") 73 | } 74 | -------------------------------------------------------------------------------- /examples/couchserver/views.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/dustin/go-couch" 11 | ) 12 | 13 | const groupsjson = `{ 14 | "_id": "_design/groups", 15 | "language": "javascript", 16 | "views": { 17 | "discovered": { 18 | "map": "function(doc) {\n if (doc.type === \"group\") {\n emit(doc._id, [doc.description, 0, 0, 0]);\n } else if (doc.type === \"article\") {\n var groups = doc.headers[\"Newsgroups\"][0].split(\",\")\n for (var i = 0; i < groups.length; i++) {\n var g = groups[i].replace(/\\s+/g, '');\n emit(g, [\"\", 1, doc.nums[g], doc.nums[g]]);\n }\n }\n}\n", 19 | "reduce": "function (key, values) {\n var result = [\"\", 0, 0, 0];\n\n values.forEach(function(p) {\n if (p[0].length > result[0].length) {\n result[0] = p[0];\n }\n result[1] += p[1];\n\tresult[2] = Math.min(result[2], p[2]);\n\tresult[3] = Math.max(result[3], p[3]);\n // Dumb special case\n if (result[2] === 0 && result[1] != 0) {\n result[2] = 1;\n }\n });\n\n return result;\n}" 20 | }, 21 | "active": { 22 | "map": "function(doc) {\n if (doc.type === \"group\") {\n emit(doc._id, [doc.description, 0, 0, 0]);\n } else if (doc.type === \"article\") {\n for (var g in doc.nums) {\n emit(g, [\"\", 1, doc.nums[g], doc.nums[g]]);\n }\n }\n}\n", 23 | "reduce": "function (key, values) {\n var result = [\"\", 0, 0, 0];\n\n values.forEach(function(p) {\n if (p[0].length > result[0].length) {\n result[0] = p[0];\n }\n result[1] += p[1];\n\tresult[2] = Math.min(result[2], p[2]);\n\tresult[3] = Math.max(result[3], p[3]);\n // Dumb special case\n if (result[2] === 0 && result[1] != 0) {\n result[2] = 1;\n }\n });\n\n return result;\n}" 24 | } 25 | } 26 | }` 27 | 28 | const articlesjson = `{ 29 | "_id": "_design/articles", 30 | "language": "javascript", 31 | "views": { 32 | "list": { 33 | "map": "function(doc) {\n if (doc.type === \"article\") {\n for (var g in doc.nums) {\n emit([g, doc.nums[g]], null);\n }\n }\n}\n", 34 | "reduce": "_count" 35 | } 36 | } 37 | }` 38 | 39 | func viewUpdateOK(i int) bool { 40 | return i == 200 || i == 409 41 | } 42 | 43 | func updateView(db *couch.Database, viewdata string) error { 44 | r, err := http.Post(db.DBURL(), "application/json", strings.NewReader(viewdata)) 45 | if r == nil { 46 | defer r.Body.Close() 47 | } else { 48 | return err 49 | } 50 | if !viewUpdateOK(r.StatusCode) { 51 | return fmt.Errorf("error updating view: %v", r.Status) 52 | } 53 | return nil 54 | } 55 | 56 | func ensureViews(db *couch.Database) error { 57 | errg := updateView(db, groupsjson) 58 | if errg != nil { 59 | log.Printf("Error creating groups view %v", errg) 60 | } 61 | 62 | erra := updateView(db, articlesjson) 63 | if erra != nil { 64 | log.Printf("Error creating articles view %v", erra) 65 | } 66 | 67 | if erra != nil || errg != nil { 68 | return errors.New("error making views") 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /examples/server/exampleserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "container/ring" 6 | "io" 7 | "log" 8 | "net" 9 | "net/textproto" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/dustin/go-nntp" 15 | "github.com/dustin/go-nntp/server" 16 | ) 17 | 18 | const maxArticles = 100 19 | 20 | type articleRef struct { 21 | msgid string 22 | num int64 23 | } 24 | 25 | type groupStorage struct { 26 | group *nntp.Group 27 | // article refs 28 | articles *ring.Ring 29 | } 30 | 31 | type articleStorage struct { 32 | headers textproto.MIMEHeader 33 | body string 34 | refcount int 35 | } 36 | 37 | type testBackendType struct { 38 | // group name -> group storage 39 | groups map[string]*groupStorage 40 | // message ID -> article 41 | articles map[string]*articleStorage 42 | } 43 | 44 | var testBackend = testBackendType{ 45 | groups: map[string]*groupStorage{}, 46 | articles: map[string]*articleStorage{}, 47 | } 48 | 49 | func init() { 50 | 51 | testBackend.groups["alt.test"] = &groupStorage{ 52 | group: &nntp.Group{ 53 | Name: "alt.test", 54 | Description: "A test.", 55 | Posting: nntp.PostingNotPermitted}, 56 | articles: ring.New(maxArticles), 57 | } 58 | 59 | testBackend.groups["misc.test"] = &groupStorage{ 60 | group: &nntp.Group{ 61 | Name: "misc.test", 62 | Description: "More testing.", 63 | Posting: nntp.PostingPermitted}, 64 | articles: ring.New(maxArticles), 65 | } 66 | 67 | } 68 | 69 | func (tb *testBackendType) ListGroups(max int) ([]*nntp.Group, error) { 70 | rv := []*nntp.Group{} 71 | for _, g := range tb.groups { 72 | rv = append(rv, g.group) 73 | } 74 | return rv, nil 75 | } 76 | 77 | func (tb *testBackendType) GetGroup(name string) (*nntp.Group, error) { 78 | var group *nntp.Group 79 | 80 | for _, g := range tb.groups { 81 | if g.group.Name == name { 82 | group = g.group 83 | break 84 | } 85 | } 86 | 87 | if group == nil { 88 | return nil, nntpserver.ErrNoSuchGroup 89 | } 90 | 91 | return group, nil 92 | } 93 | 94 | func mkArticle(a *articleStorage) *nntp.Article { 95 | return &nntp.Article{ 96 | Header: a.headers, 97 | Body: strings.NewReader(a.body), 98 | Bytes: len(a.body), 99 | Lines: strings.Count(a.body, "\n"), 100 | } 101 | } 102 | 103 | func findInRing(in *ring.Ring, f func(r interface{}) bool) *ring.Ring { 104 | if f(in.Value) { 105 | return in 106 | } 107 | for p := in.Next(); p != in; p = p.Next() { 108 | if f(p.Value) { 109 | return p 110 | } 111 | } 112 | return nil 113 | } 114 | 115 | func (tb *testBackendType) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) { 116 | 117 | msgID := id 118 | var a *articleStorage 119 | 120 | if intid, err := strconv.ParseInt(id, 10, 64); err == nil { 121 | msgID = "" 122 | // by int ID. Gotta go find it. 123 | if groupStorage, ok := tb.groups[group.Name]; ok { 124 | r := findInRing(groupStorage.articles, func(v interface{}) bool { 125 | if v != nil { 126 | log.Printf("Looking at %v", v) 127 | } 128 | if aref, ok := v.(articleRef); ok && aref.num == intid { 129 | return true 130 | } 131 | return false 132 | }) 133 | if aref, ok := r.Value.(articleRef); ok { 134 | msgID = aref.msgid 135 | } 136 | } 137 | } 138 | 139 | a = tb.articles[msgID] 140 | if a == nil { 141 | return nil, nntpserver.ErrInvalidMessageID 142 | } 143 | 144 | return mkArticle(a), nil 145 | } 146 | 147 | // Because I suck at ring, I'm going to just post-sort these. 148 | type nalist []nntpserver.NumberedArticle 149 | 150 | func (n nalist) Len() int { 151 | return len(n) 152 | } 153 | 154 | func (n nalist) Less(i, j int) bool { 155 | return n[i].Num < n[j].Num 156 | } 157 | 158 | func (n nalist) Swap(i, j int) { 159 | n[i], n[j] = n[j], n[i] 160 | } 161 | 162 | func (tb *testBackendType) GetArticles(group *nntp.Group, 163 | from, to int64) ([]nntpserver.NumberedArticle, error) { 164 | 165 | gs, ok := tb.groups[group.Name] 166 | if !ok { 167 | return nil, nntpserver.ErrNoSuchGroup 168 | } 169 | 170 | log.Printf("Getting articles from %d to %d", from, to) 171 | 172 | rv := []nntpserver.NumberedArticle{} 173 | gs.articles.Do(func(v interface{}) { 174 | if v != nil { 175 | if aref, ok := v.(articleRef); ok { 176 | if aref.num >= from && aref.num <= to { 177 | a, ok := tb.articles[aref.msgid] 178 | if ok { 179 | article := mkArticle(a) 180 | rv = append(rv, 181 | nntpserver.NumberedArticle{ 182 | Num: aref.num, 183 | Article: article}) 184 | } 185 | } 186 | } 187 | } 188 | }) 189 | 190 | sort.Sort(nalist(rv)) 191 | 192 | return rv, nil 193 | } 194 | 195 | func (tb *testBackendType) AllowPost() bool { 196 | return true 197 | } 198 | 199 | func (tb *testBackendType) decr(msgid string) { 200 | if a, ok := tb.articles[msgid]; ok { 201 | a.refcount-- 202 | if a.refcount == 0 { 203 | log.Printf("Getting rid of %v", msgid) 204 | delete(tb.articles, msgid) 205 | } 206 | } 207 | } 208 | 209 | func (tb *testBackendType) Post(article *nntp.Article) error { 210 | log.Printf("Got headers: %#v", article.Header) 211 | b := []byte{} 212 | buf := bytes.NewBuffer(b) 213 | n, err := io.Copy(buf, article.Body) 214 | if err != nil { 215 | return err 216 | } 217 | log.Printf("Read %d bytes of body", n) 218 | 219 | a := articleStorage{ 220 | headers: article.Header, 221 | body: buf.String(), 222 | refcount: 0, 223 | } 224 | 225 | msgID := a.headers.Get("Message-Id") 226 | 227 | if _, ok := tb.articles[msgID]; ok { 228 | return nntpserver.ErrPostingFailed 229 | } 230 | 231 | for _, g := range article.Header["Newsgroups"] { 232 | if g, ok := tb.groups[g]; ok { 233 | g.articles = g.articles.Next() 234 | if g.articles.Value != nil { 235 | aref := g.articles.Value.(articleRef) 236 | tb.decr(aref.msgid) 237 | } 238 | if g.articles.Value != nil || g.group.Low == 0 { 239 | g.group.Low++ 240 | } 241 | g.group.High++ 242 | g.articles.Value = articleRef{ 243 | msgID, 244 | g.group.High, 245 | } 246 | log.Printf("Placed %v", g.articles.Value) 247 | a.refcount++ 248 | g.group.Count = int64(g.articles.Len()) 249 | 250 | log.Printf("Stored %v in %v", msgID, g.group.Name) 251 | } 252 | } 253 | 254 | if a.refcount > 0 { 255 | tb.articles[msgID] = &a 256 | } else { 257 | return nntpserver.ErrPostingFailed 258 | } 259 | 260 | return nil 261 | } 262 | 263 | func (tb *testBackendType) Authorized() bool { 264 | return true 265 | } 266 | 267 | func (tb *testBackendType) Authenticate(user, pass string) (nntpserver.Backend, error) { 268 | return nil, nntpserver.ErrAuthRejected 269 | } 270 | 271 | func maybefatal(err error, f string, a ...interface{}) { 272 | if err != nil { 273 | log.Fatalf(f, a...) 274 | } 275 | } 276 | 277 | func main() { 278 | a, err := net.ResolveTCPAddr("tcp", ":1119") 279 | maybefatal(err, "Error resolving listener: %v", err) 280 | l, err := net.ListenTCP("tcp", a) 281 | maybefatal(err, "Error setting up listener: %v", err) 282 | defer l.Close() 283 | 284 | s := nntpserver.NewServer(&testBackend) 285 | 286 | for { 287 | c, err := l.AcceptTCP() 288 | maybefatal(err, "Error accepting connection: %v", err) 289 | go s.Process(c) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /examples/couchserver/couchserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "log/syslog" 12 | "net" 13 | "net/textproto" 14 | "net/url" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "sync/atomic" 19 | "time" 20 | 21 | "github.com/dustin/go-nntp" 22 | "github.com/dustin/go-nntp/server" 23 | 24 | "github.com/dustin/go-couch" 25 | ) 26 | 27 | var groupCacheTimeout = flag.Int("groupTimeout", 300, 28 | "Time (in seconds), group cache is valid") 29 | var optimisticPost = flag.Bool("optimistic", false, 30 | "Optimistically return success on store before storing") 31 | var useSyslog = flag.Bool("syslog", false, 32 | "Log to syslog") 33 | 34 | type groupRow struct { 35 | Group string `json:"key"` 36 | Value []interface{} `json:"value"` 37 | } 38 | 39 | type groupResults struct { 40 | Rows []groupRow 41 | } 42 | 43 | type attachment struct { 44 | Type string `json:"content-type"` 45 | Data []byte `json:"data"` 46 | } 47 | 48 | func removeSpace(r rune) rune { 49 | if r == ' ' || r == '\n' || r == '\r' { 50 | return -1 51 | } 52 | return r 53 | } 54 | 55 | func (a *attachment) MarshalJSON() ([]byte, error) { 56 | m := map[string]string{ 57 | "content_type": a.Type, 58 | "data": strings.Map(removeSpace, base64.StdEncoding.EncodeToString(a.Data)), 59 | } 60 | return json.Marshal(m) 61 | } 62 | 63 | type article struct { 64 | MsgID string `json:"_id"` 65 | DocType string `json:"type"` 66 | Headers map[string][]string `json:"headers"` 67 | Bytes int `json:"bytes"` 68 | Lines int `json:"lines"` 69 | Nums map[string]int64 `json:"nums"` 70 | Attachments map[string]*attachment `json:"_attachments"` 71 | Added time.Time `json:"added"` 72 | } 73 | 74 | type articleResults struct { 75 | Rows []struct { 76 | Key []interface{} `json:"key"` 77 | Article article `json:"doc"` 78 | } 79 | } 80 | 81 | type couchBackend struct { 82 | db *couch.Database 83 | groups map[string]*nntp.Group 84 | grouplock sync.Mutex 85 | } 86 | 87 | func (cb *couchBackend) clearGroups() { 88 | cb.grouplock.Lock() 89 | defer cb.grouplock.Unlock() 90 | 91 | log.Printf("Dumping group cache") 92 | cb.groups = nil 93 | } 94 | 95 | func (cb *couchBackend) fetchGroups() error { 96 | cb.grouplock.Lock() 97 | defer cb.grouplock.Unlock() 98 | 99 | if cb.groups != nil { 100 | return nil 101 | } 102 | 103 | log.Printf("Filling group cache") 104 | 105 | results := groupResults{} 106 | err := cb.db.Query("_design/groups/_view/active", map[string]interface{}{ 107 | "group": true, 108 | }, &results) 109 | if err != nil { 110 | return err 111 | } 112 | cb.groups = make(map[string]*nntp.Group) 113 | for _, gr := range results.Rows { 114 | if gr.Value[0].(string) != "" { 115 | group := nntp.Group{ 116 | Name: gr.Group, 117 | Description: gr.Value[0].(string), 118 | Count: int64(gr.Value[1].(float64)), 119 | Low: int64(gr.Value[2].(float64)), 120 | High: int64(gr.Value[3].(float64)), 121 | Posting: nntp.PostingPermitted, 122 | } 123 | cb.groups[group.Name] = &group 124 | } 125 | } 126 | 127 | go func() { 128 | time.Sleep(time.Duration(*groupCacheTimeout) * time.Second) 129 | cb.clearGroups() 130 | }() 131 | 132 | return nil 133 | } 134 | 135 | func (cb *couchBackend) ListGroups(max int) ([]*nntp.Group, error) { 136 | if cb.groups == nil { 137 | if err := cb.fetchGroups(); err != nil { 138 | return nil, err 139 | } 140 | } 141 | rv := make([]*nntp.Group, 0, len(cb.groups)) 142 | for _, g := range cb.groups { 143 | rv = append(rv, g) 144 | } 145 | return rv, nil 146 | } 147 | 148 | func (cb *couchBackend) GetGroup(name string) (*nntp.Group, error) { 149 | if cb.groups == nil { 150 | if err := cb.fetchGroups(); err != nil { 151 | return nil, err 152 | } 153 | } 154 | g, exists := cb.groups[name] 155 | if !exists { 156 | return nil, nntpserver.ErrNoSuchGroup 157 | } 158 | return g, nil 159 | } 160 | 161 | func (cb *couchBackend) mkArticle(ar article) *nntp.Article { 162 | url := fmt.Sprintf("%s/%s/article", cb.db.DBURL(), cleanupID(ar.MsgID, true)) 163 | return &nntp.Article{ 164 | Header: textproto.MIMEHeader(ar.Headers), 165 | Body: &lazyOpener{url, nil, nil}, 166 | Bytes: ar.Bytes, 167 | Lines: ar.Lines, 168 | } 169 | } 170 | 171 | func (cb *couchBackend) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) { 172 | var ar article 173 | if intid, err := strconv.ParseInt(id, 10, 64); err == nil { 174 | results := articleResults{} 175 | cb.db.Query("_design/articles/_view/list", map[string]interface{}{ 176 | "include_docs": true, 177 | "reduce": false, 178 | "key": []interface{}{group.Name, intid}, 179 | }, &results) 180 | 181 | if len(results.Rows) != 1 { 182 | return nil, nntpserver.ErrInvalidArticleNumber 183 | } 184 | 185 | ar = results.Rows[0].Article 186 | } else { 187 | err := cb.db.Retrieve(cleanupID(id, false), &ar) 188 | if err != nil { 189 | return nil, nntpserver.ErrInvalidMessageID 190 | } 191 | } 192 | 193 | return cb.mkArticle(ar), nil 194 | } 195 | 196 | func (cb *couchBackend) GetArticles(group *nntp.Group, 197 | from, to int64) ([]nntpserver.NumberedArticle, error) { 198 | 199 | rv := make([]nntpserver.NumberedArticle, 0, 100) 200 | 201 | results := articleResults{} 202 | cb.db.Query("_design/articles/_view/list", map[string]interface{}{ 203 | "include_docs": true, 204 | "reduce": false, 205 | "start_key": []interface{}{group.Name, from}, 206 | "end_key": []interface{}{group.Name, to}, 207 | }, &results) 208 | 209 | for _, r := range results.Rows { 210 | rv = append(rv, nntpserver.NumberedArticle{ 211 | Num: int64(r.Key[1].(float64)), 212 | Article: cb.mkArticle(r.Article), 213 | }) 214 | } 215 | 216 | return rv, nil 217 | } 218 | 219 | func (cb *couchBackend) AllowPost() bool { 220 | return true 221 | } 222 | 223 | func cleanupID(msgid string, escapedAt bool) string { 224 | s := strings.TrimFunc(msgid, func(r rune) bool { 225 | return r == ' ' || r == '<' || r == '>' 226 | }) 227 | qe := url.QueryEscape(s) 228 | if escapedAt { 229 | return qe 230 | } 231 | return strings.Replace(qe, "%40", "@", -1) 232 | } 233 | 234 | func (cb *couchBackend) Post(art *nntp.Article) error { 235 | a := article{ 236 | DocType: "article", 237 | Headers: map[string][]string(art.Header), 238 | Nums: make(map[string]int64), 239 | MsgID: cleanupID(art.Header.Get("Message-Id"), false), 240 | Attachments: make(map[string]*attachment), 241 | Added: time.Now(), 242 | } 243 | 244 | b := []byte{} 245 | buf := bytes.NewBuffer(b) 246 | n, err := io.Copy(buf, art.Body) 247 | if err != nil { 248 | return err 249 | } 250 | log.Printf("Read %d bytes of body", n) 251 | 252 | b = buf.Bytes() 253 | a.Bytes = len(b) 254 | a.Lines = bytes.Count(b, []byte{'\n'}) 255 | 256 | a.Attachments["article"] = &attachment{"text/plain", b} 257 | 258 | for _, g := range strings.Split(art.Header.Get("Newsgroups"), ",") { 259 | g = strings.TrimSpace(g) 260 | group, err := cb.GetGroup(g) 261 | if err == nil { 262 | a.Nums[g] = atomic.AddInt64(&group.High, 1) 263 | atomic.AddInt64(&group.Count, 1) 264 | } else { 265 | log.Printf("Error getting group %q: %v", g, err) 266 | } 267 | } 268 | 269 | if len(a.Nums) == 0 { 270 | log.Printf("Found no matching groups in %v", 271 | art.Header["Newsgroups"]) 272 | return nntpserver.ErrPostingFailed 273 | } 274 | 275 | if *optimisticPost { 276 | go func() { 277 | _, _, err = cb.db.Insert(&a) 278 | if err != nil { 279 | log.Printf("error optimistically posting article: %v", err) 280 | } 281 | }() 282 | } else { 283 | _, _, err = cb.db.Insert(&a) 284 | if err != nil { 285 | log.Printf("error posting article: %v", err) 286 | return nntpserver.ErrPostingFailed 287 | } 288 | } 289 | 290 | return nil 291 | } 292 | 293 | func (cb *couchBackend) Authorized() bool { 294 | return true 295 | } 296 | 297 | func (cb *couchBackend) Authenticate(user, pass string) (nntpserver.Backend, error) { 298 | return nil, nntpserver.ErrAuthRejected 299 | } 300 | 301 | func maybefatal(err error, f string, a ...interface{}) { 302 | if err != nil { 303 | log.Fatalf(f, a...) 304 | } 305 | } 306 | 307 | func main() { 308 | couchURL := flag.String("couch", "http://localhost:5984/news", 309 | "Couch DB.") 310 | 311 | flag.Parse() 312 | 313 | if *useSyslog { 314 | sl, err := syslog.New(syslog.LOG_INFO, "nntpd") 315 | if err != nil { 316 | log.Fatalf("Error initializing syslog: %v", err) 317 | } 318 | log.SetOutput(sl) 319 | log.SetFlags(0) 320 | } 321 | 322 | a, err := net.ResolveTCPAddr("tcp", ":1119") 323 | maybefatal(err, "Error resolving listener: %v", err) 324 | l, err := net.ListenTCP("tcp", a) 325 | maybefatal(err, "Error setting up listener: %v", err) 326 | defer l.Close() 327 | 328 | db, err := couch.Connect(*couchURL) 329 | maybefatal(err, "Can't connect to the couch: %v", err) 330 | err = ensureViews(&db) 331 | maybefatal(err, "Error setting up views: %v", err) 332 | 333 | backend := couchBackend{ 334 | db: &db, 335 | } 336 | 337 | s := nntpserver.NewServer(&backend) 338 | 339 | for { 340 | c, err := l.AcceptTCP() 341 | maybefatal(err, "Error accepting connection: %v", err) 342 | go s.Process(c) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | // Package nntpclient provides an NNTP Client. 2 | package nntpclient 3 | 4 | import ( 5 | "crypto/tls" 6 | "errors" 7 | "io" 8 | "net" 9 | "net/textproto" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/dustin/go-nntp" 14 | ) 15 | 16 | // Client is an NNTP client. 17 | type Client struct { 18 | conn *textproto.Conn 19 | netconn net.Conn 20 | tls bool 21 | Banner string 22 | capabilities []string 23 | } 24 | 25 | // New connects a client to an NNTP server. 26 | func New(network, addr string) (*Client, error) { 27 | netconn, err := net.Dial(network, addr) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return connect(netconn) 32 | } 33 | 34 | // NewConn wraps an existing connection, for example one opened with tls.Dial 35 | func NewConn(netconn net.Conn) (*Client, error) { 36 | client, err := connect(netconn) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if _, ok := netconn.(*tls.Conn); ok { 41 | client.tls = true 42 | } 43 | return client, nil 44 | } 45 | 46 | // NewTLS connects to an NNTP server over a dedicated TLS port like 563 47 | func NewTLS(network, addr string, config *tls.Config) (*Client, error) { 48 | netconn, err := tls.Dial(network, addr, config) 49 | if err != nil { 50 | return nil, err 51 | } 52 | client, err := connect(netconn) 53 | if err != nil { 54 | return nil, err 55 | } 56 | client.tls = true 57 | return client, nil 58 | } 59 | 60 | func connect(netconn net.Conn) (*Client, error) { 61 | conn := textproto.NewConn(netconn) 62 | _, msg, err := conn.ReadCodeLine(200) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return &Client{ 68 | conn: conn, 69 | netconn: netconn, 70 | Banner: msg, 71 | }, nil 72 | } 73 | 74 | // Close this client. 75 | func (c *Client) Close() error { 76 | return c.conn.Close() 77 | } 78 | 79 | // Authenticate against an NNTP server using authinfo user/pass 80 | func (c *Client) Authenticate(user, pass string) (msg string, err error) { 81 | err = c.conn.PrintfLine("authinfo user %s", user) 82 | if err != nil { 83 | return 84 | } 85 | _, _, err = c.conn.ReadCodeLine(381) 86 | if err != nil { 87 | return 88 | } 89 | 90 | err = c.conn.PrintfLine("authinfo pass %s", pass) 91 | if err != nil { 92 | return 93 | } 94 | _, msg, err = c.conn.ReadCodeLine(281) 95 | return 96 | } 97 | 98 | func parsePosting(p string) nntp.PostingStatus { 99 | switch p { 100 | case "y": 101 | return nntp.PostingPermitted 102 | case "m": 103 | return nntp.PostingModerated 104 | } 105 | return nntp.PostingNotPermitted 106 | } 107 | 108 | // List groups 109 | func (c *Client) List(sub string) (rv []nntp.Group, err error) { 110 | _, _, err = c.Command("LIST "+sub, 215) 111 | if err != nil { 112 | return 113 | } 114 | var groupLines []string 115 | groupLines, err = c.conn.ReadDotLines() 116 | if err != nil { 117 | return 118 | } 119 | rv = make([]nntp.Group, 0, len(groupLines)) 120 | for _, l := range groupLines { 121 | parts := strings.Split(l, " ") 122 | high, errh := strconv.ParseInt(parts[1], 10, 64) 123 | low, errl := strconv.ParseInt(parts[2], 10, 64) 124 | if errh == nil && errl == nil { 125 | rv = append(rv, nntp.Group{ 126 | Name: parts[0], 127 | High: high, 128 | Low: low, 129 | Posting: parsePosting(parts[3]), 130 | }) 131 | } 132 | } 133 | return 134 | } 135 | 136 | // Group selects a group. 137 | func (c *Client) Group(name string) (rv nntp.Group, err error) { 138 | var msg string 139 | _, msg, err = c.Command("GROUP "+name, 211) 140 | if err != nil { 141 | return 142 | } 143 | // count first last name 144 | parts := strings.Split(msg, " ") 145 | if len(parts) != 4 { 146 | err = errors.New("Don't know how to parse result: " + msg) 147 | } 148 | rv.Count, err = strconv.ParseInt(parts[0], 10, 64) 149 | if err != nil { 150 | return 151 | } 152 | rv.Low, err = strconv.ParseInt(parts[1], 10, 64) 153 | if err != nil { 154 | return 155 | } 156 | rv.High, err = strconv.ParseInt(parts[2], 10, 64) 157 | if err != nil { 158 | return 159 | } 160 | rv.Name = parts[3] 161 | 162 | return 163 | } 164 | 165 | // Article grabs an article 166 | func (c *Client) Article(specifier string) (int64, string, io.Reader, error) { 167 | err := c.conn.PrintfLine("ARTICLE %s", specifier) 168 | if err != nil { 169 | return 0, "", nil, err 170 | } 171 | return c.articleish(220) 172 | } 173 | 174 | // Head gets the headers for an article 175 | func (c *Client) Head(specifier string) (int64, string, io.Reader, error) { 176 | err := c.conn.PrintfLine("HEAD %s", specifier) 177 | if err != nil { 178 | return 0, "", nil, err 179 | } 180 | return c.articleish(221) 181 | } 182 | 183 | // Body gets the body of an article 184 | func (c *Client) Body(specifier string) (int64, string, io.Reader, error) { 185 | err := c.conn.PrintfLine("BODY %s", specifier) 186 | if err != nil { 187 | return 0, "", nil, err 188 | } 189 | return c.articleish(222) 190 | } 191 | 192 | func (c *Client) articleish(expected int) (int64, string, io.Reader, error) { 193 | _, msg, err := c.conn.ReadCodeLine(expected) 194 | if err != nil { 195 | return 0, "", nil, err 196 | } 197 | parts := strings.SplitN(msg, " ", 2) 198 | n, err := strconv.ParseInt(parts[0], 10, 64) 199 | if err != nil { 200 | return 0, "", nil, err 201 | } 202 | return n, parts[1], c.conn.DotReader(), nil 203 | } 204 | 205 | // Post a new article 206 | // 207 | // The reader should contain the entire article, headers and body in 208 | // RFC822ish format. 209 | func (c *Client) Post(r io.Reader) error { 210 | err := c.conn.PrintfLine("POST") 211 | if err != nil { 212 | return err 213 | } 214 | _, _, err = c.conn.ReadCodeLine(340) 215 | if err != nil { 216 | return err 217 | } 218 | w := c.conn.DotWriter() 219 | _, err = io.Copy(w, r) 220 | if err != nil { 221 | // This seems really bad 222 | return err 223 | } 224 | w.Close() 225 | _, _, err = c.conn.ReadCodeLine(240) 226 | return err 227 | } 228 | 229 | // Command sends a low-level command and get a response. 230 | // 231 | // This will return an error if the code doesn't match the expectCode 232 | // prefix. For example, if you specify "200", the response code MUST 233 | // be 200 or you'll get an error. If you specify "2", any code from 234 | // 200 (inclusive) to 300 (exclusive) will be success. An expectCode 235 | // of -1 disables this behavior. 236 | func (c *Client) Command(cmd string, expectCode int) (int, string, error) { 237 | err := c.conn.PrintfLine(cmd) 238 | if err != nil { 239 | return 0, "", err 240 | } 241 | return c.conn.ReadCodeLine(expectCode) 242 | } 243 | 244 | // asLines issues a command and returns the response's data block as lines. 245 | func (c *Client) asLines(cmd string, expectCode int) ([]string, error) { 246 | _, _, err := c.Command(cmd, expectCode) 247 | if err != nil { 248 | return nil, err 249 | } 250 | return c.conn.ReadDotLines() 251 | } 252 | 253 | // Capabilities retrieves a list of supported capabilities. 254 | // 255 | // See https://datatracker.ietf.org/doc/html/rfc3977#section-5.2.2 256 | func (c *Client) Capabilities() ([]string, error) { 257 | caps, err := c.asLines("CAPABILITIES", 101) 258 | if err != nil { 259 | return nil, err 260 | } 261 | for i, line := range caps { 262 | caps[i] = strings.ToUpper(line) 263 | } 264 | c.capabilities = caps 265 | return caps, nil 266 | } 267 | 268 | // GetCapability returns a complete capability line. 269 | // 270 | // "Each capability line consists of one or more tokens, which MUST be 271 | // separated by one or more space or TAB characters." 272 | // 273 | // From https://datatracker.ietf.org/doc/html/rfc3977#section-3.3.1 274 | func (c *Client) GetCapability(capability string) string { 275 | capability = strings.ToUpper(capability) 276 | for _, capa := range c.capabilities { 277 | i := strings.IndexAny(capa, "\t ") 278 | if i != -1 && capa[:i] == capability { 279 | return capa 280 | } 281 | if capa == capability { 282 | return capa 283 | } 284 | } 285 | return "" 286 | } 287 | 288 | // HasCapabilityArgument indicates whether a capability arg is supported. 289 | // 290 | // Here, "argument" means any token after the label in a capabilities response 291 | // line. Some, like "ACTIVE" in "LIST ACTIVE", are not command arguments but 292 | // rather "keyword" components of compound commands called "variants." 293 | // 294 | // See https://datatracker.ietf.org/doc/html/rfc3977#section-9.5 295 | func (c *Client) HasCapabilityArgument( 296 | capability, argument string, 297 | ) (bool, error) { 298 | if c.capabilities == nil { 299 | return false, errors.New("Capabilities unpopulated") 300 | } 301 | capLine := c.GetCapability(capability) 302 | if capLine == "" { 303 | return false, errors.New("No such capability") 304 | } 305 | argument = strings.ToUpper(argument) 306 | for _, capArg := range strings.Fields(capLine)[1:] { 307 | if capArg == argument { 308 | return true, nil 309 | } 310 | } 311 | return false, nil 312 | } 313 | 314 | // ListOverviewFmt performs a LIST OVERVIEW.FMT query. 315 | // 316 | // According to the spec, the presence of an "OVER" line in the capabilities 317 | // response means this LIST variant is supported, so there's no reason to 318 | // check for it among the keywords in the "LIST" line, strictly speaking. 319 | // 320 | // See https://datatracker.ietf.org/doc/html/rfc3977#section-3.3.2 321 | func (c *Client) ListOverviewFmt() ([]string, error) { 322 | fields, err := c.asLines("LIST OVERVIEW.FMT", 215) 323 | if err != nil { 324 | return nil, err 325 | } 326 | return fields, nil 327 | } 328 | 329 | // Over returns a list of raw overview lines with tab-separated fields. 330 | func (c *Client) Over(specifier string) ([]string, error) { 331 | lines, err := c.asLines("OVER "+specifier, 224) 332 | if err != nil { 333 | return nil, err 334 | } 335 | return lines, nil 336 | } 337 | 338 | func (c *Client) HasTLS() bool { 339 | return c.tls 340 | } 341 | 342 | // StartTLS sends the STARTTLS command and refreshes capabilities. 343 | // 344 | // See https://datatracker.ietf.org/doc/html/rfc4642 and net/smtp.go, from 345 | // which this was adapted, and maybe NNTP.startls in Python's nntplib also. 346 | func (c *Client) StartTLS(config *tls.Config) error { 347 | if c.tls { 348 | return errors.New("TLS already active") 349 | } 350 | _, _, err := c.Command("STARTTLS", 382) 351 | if err != nil { 352 | return err 353 | } 354 | c.netconn = tls.Client(c.netconn, config) 355 | c.conn = textproto.NewConn(c.netconn) 356 | c.tls = true 357 | _, err = c.Capabilities() 358 | if err != nil { 359 | return err 360 | } 361 | return nil 362 | } 363 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | // Package nntpserver provides everything you need for your own NNTP server. 2 | package nntpserver 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "log" 8 | "math" 9 | "net" 10 | "net/textproto" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/dustin/go-nntp" 15 | ) 16 | 17 | // An NNTPError is a coded NNTP error message. 18 | type NNTPError struct { 19 | Code int 20 | Msg string 21 | } 22 | 23 | // ErrNoSuchGroup is returned for a request for a group that can't be found. 24 | var ErrNoSuchGroup = &NNTPError{411, "No such newsgroup"} 25 | 26 | // ErrNoSuchGroup is returned for a request that requires a current 27 | // group when none has been selected. 28 | var ErrNoGroupSelected = &NNTPError{412, "No newsgroup selected"} 29 | 30 | // ErrInvalidMessageID is returned when a message is requested that can't be found. 31 | var ErrInvalidMessageID = &NNTPError{430, "No article with that message-id"} 32 | 33 | // ErrInvalidArticleNumber is returned when an article is requested that can't be found. 34 | var ErrInvalidArticleNumber = &NNTPError{423, "No article with that number"} 35 | 36 | // ErrNoCurrentArticle is returned when a command is executed that 37 | // requires a current article when one has not been selected. 38 | var ErrNoCurrentArticle = &NNTPError{420, "Current article number is invalid"} 39 | 40 | // ErrUnknownCommand is returned for unknown comands. 41 | var ErrUnknownCommand = &NNTPError{500, "Unknown command"} 42 | 43 | // ErrSyntax is returned when a command can't be parsed. 44 | var ErrSyntax = &NNTPError{501, "not supported, or syntax error"} 45 | 46 | // ErrPostingNotPermitted is returned as the response to an attempt to 47 | // post an article where posting is not permitted. 48 | var ErrPostingNotPermitted = &NNTPError{440, "Posting not permitted"} 49 | 50 | // ErrPostingFailed is returned when an attempt to post an article fails. 51 | var ErrPostingFailed = &NNTPError{441, "posting failed"} 52 | 53 | // ErrNotWanted is returned when an attempt to post an article is 54 | // rejected due the server not wanting the article. 55 | var ErrNotWanted = &NNTPError{435, "Article not wanted"} 56 | 57 | // ErrAuthRequired is returned to indicate authentication is required 58 | // to proceed. 59 | var ErrAuthRequired = &NNTPError{450, "authorization required"} 60 | 61 | // ErrAuthRejected is returned for invalid authentication. 62 | var ErrAuthRejected = &NNTPError{452, "authorization rejected"} 63 | 64 | // ErrNotAuthenticated is returned when a command is issued that requires 65 | // authentication, but authentication was not provided. 66 | var ErrNotAuthenticated = &NNTPError{480, "authentication required"} 67 | 68 | // Handler is a low-level protocol handler 69 | type Handler func(args []string, s *session, c *textproto.Conn) error 70 | 71 | // A NumberedArticle provides local sequence nubers to articles When 72 | // listing articles in a group. 73 | type NumberedArticle struct { 74 | Num int64 75 | Article *nntp.Article 76 | } 77 | 78 | // The Backend that provides the things and does the stuff. 79 | type Backend interface { 80 | ListGroups(max int) ([]*nntp.Group, error) 81 | GetGroup(name string) (*nntp.Group, error) 82 | GetArticle(group *nntp.Group, id string) (*nntp.Article, error) 83 | GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error) 84 | Authorized() bool 85 | // Authenticate and optionally swap out the backend for this session. 86 | // You may return nil to continue using the same backend. 87 | Authenticate(user, pass string) (Backend, error) 88 | AllowPost() bool 89 | Post(article *nntp.Article) error 90 | } 91 | 92 | type session struct { 93 | server *Server 94 | backend Backend 95 | group *nntp.Group 96 | } 97 | 98 | // The Server handle. 99 | type Server struct { 100 | // Handlers are dispatched by command name. 101 | Handlers map[string]Handler 102 | // The backend (your code) that provides data 103 | Backend Backend 104 | // The currently selected group. 105 | group *nntp.Group 106 | } 107 | 108 | // NewServer builds a new server handle request to a backend. 109 | func NewServer(backend Backend) *Server { 110 | rv := Server{ 111 | Handlers: make(map[string]Handler), 112 | Backend: backend, 113 | } 114 | rv.Handlers[""] = handleDefault 115 | rv.Handlers["quit"] = handleQuit 116 | rv.Handlers["group"] = handleGroup 117 | rv.Handlers["list"] = handleList 118 | rv.Handlers["head"] = handleHead 119 | rv.Handlers["body"] = handleBody 120 | rv.Handlers["article"] = handleArticle 121 | rv.Handlers["post"] = handlePost 122 | rv.Handlers["ihave"] = handleIHave 123 | rv.Handlers["capabilities"] = handleCap 124 | rv.Handlers["mode"] = handleMode 125 | rv.Handlers["authinfo"] = handleAuthInfo 126 | rv.Handlers["newgroups"] = handleNewGroups 127 | rv.Handlers["over"] = handleOver 128 | rv.Handlers["xover"] = handleOver 129 | return &rv 130 | } 131 | 132 | func (e *NNTPError) Error() string { 133 | return fmt.Sprintf("%d %s", e.Code, e.Msg) 134 | } 135 | 136 | func (s *session) dispatchCommand(cmd string, args []string, 137 | c *textproto.Conn) (err error) { 138 | 139 | handler, found := s.server.Handlers[strings.ToLower(cmd)] 140 | if !found { 141 | handler, found = s.server.Handlers[""] 142 | if !found { 143 | panic("No default handler.") 144 | } 145 | } 146 | return handler(args, s, c) 147 | } 148 | 149 | // Process an NNTP session. 150 | func (s *Server) Process(nc net.Conn) { 151 | defer nc.Close() 152 | c := textproto.NewConn(nc) 153 | 154 | sess := &session{ 155 | server: s, 156 | backend: s.Backend, 157 | group: nil, 158 | } 159 | 160 | c.PrintfLine("200 Hello!") 161 | for { 162 | l, err := c.ReadLine() 163 | if err != nil { 164 | log.Printf("Error reading from client, dropping conn: %v", err) 165 | return 166 | } 167 | cmd := strings.Split(l, " ") 168 | log.Printf("Got cmd: %+v", cmd) 169 | args := []string{} 170 | if len(cmd) > 1 { 171 | args = cmd[1:] 172 | } 173 | err = sess.dispatchCommand(cmd[0], args, c) 174 | if err != nil { 175 | _, isNNTPError := err.(*NNTPError) 176 | switch { 177 | case err == io.EOF: 178 | // Drop this connection silently. They hung up 179 | return 180 | case isNNTPError: 181 | c.PrintfLine(err.Error()) 182 | default: 183 | log.Printf("Error dispatching command, dropping conn: %v", 184 | err) 185 | return 186 | } 187 | } 188 | } 189 | } 190 | 191 | func parseRange(spec string) (low, high int64) { 192 | if spec == "" { 193 | return 0, math.MaxInt64 194 | } 195 | parts := strings.Split(spec, "-") 196 | if len(parts) == 1 { 197 | h, err := strconv.ParseInt(parts[0], 10, 64) 198 | if err != nil { 199 | h = math.MaxInt64 200 | } 201 | return 0, h 202 | } 203 | l, _ := strconv.ParseInt(parts[0], 10, 64) 204 | h, err := strconv.ParseInt(parts[1], 10, 64) 205 | if err != nil { 206 | h = math.MaxInt64 207 | } 208 | return l, h 209 | } 210 | 211 | /* 212 | "0" or article number (see below) 213 | Subject header content 214 | From header content 215 | Date header content 216 | Message-ID header content 217 | References header content 218 | :bytes metadata item 219 | :lines metadata item 220 | */ 221 | 222 | func handleOver(args []string, s *session, c *textproto.Conn) error { 223 | if s.group == nil { 224 | return ErrNoGroupSelected 225 | } 226 | from, to := parseRange(args[0]) 227 | articles, err := s.backend.GetArticles(s.group, from, to) 228 | if err != nil { 229 | return err 230 | } 231 | c.PrintfLine("224 here it comes") 232 | dw := c.DotWriter() 233 | defer dw.Close() 234 | for _, a := range articles { 235 | fmt.Fprintf(dw, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", a.Num, 236 | a.Article.Header.Get("Subject"), 237 | a.Article.Header.Get("From"), 238 | a.Article.Header.Get("Date"), 239 | a.Article.Header.Get("Message-Id"), 240 | a.Article.Header.Get("References"), 241 | a.Article.Bytes, a.Article.Lines) 242 | } 243 | return nil 244 | } 245 | 246 | func handleListOverviewFmt(c *textproto.Conn) error { 247 | err := c.PrintfLine("215 Order of fields in overview database.") 248 | if err != nil { 249 | return err 250 | } 251 | dw := c.DotWriter() 252 | defer dw.Close() 253 | _, err = fmt.Fprintln(dw, `Subject: 254 | From: 255 | Date: 256 | Message-ID: 257 | References: 258 | :bytes 259 | :lines`) 260 | return err 261 | } 262 | 263 | func handleList(args []string, s *session, c *textproto.Conn) error { 264 | ltype := "active" 265 | if len(args) > 0 { 266 | ltype = strings.ToLower(args[0]) 267 | } 268 | 269 | if ltype == "overview.fmt" { 270 | return handleListOverviewFmt(c) 271 | } 272 | 273 | groups, err := s.backend.ListGroups(-1) 274 | if err != nil { 275 | return err 276 | } 277 | c.PrintfLine("215 list of newsgroups follows") 278 | dw := c.DotWriter() 279 | defer dw.Close() 280 | for _, g := range groups { 281 | switch ltype { 282 | case "active": 283 | fmt.Fprintf(dw, "%s %d %d %v\r\n", 284 | g.Name, g.High, g.Low, g.Posting) 285 | case "newsgroups": 286 | fmt.Fprintf(dw, "%s %s\r\n", g.Name, g.Description) 287 | } 288 | } 289 | 290 | return nil 291 | } 292 | 293 | func handleNewGroups(args []string, s *session, c *textproto.Conn) error { 294 | c.PrintfLine("231 list of newsgroups follows") 295 | c.PrintfLine(".") 296 | return nil 297 | } 298 | 299 | func handleDefault(args []string, s *session, c *textproto.Conn) error { 300 | return ErrUnknownCommand 301 | } 302 | 303 | func handleQuit(args []string, s *session, c *textproto.Conn) error { 304 | c.PrintfLine("205 bye") 305 | return io.EOF 306 | } 307 | 308 | func handleGroup(args []string, s *session, c *textproto.Conn) error { 309 | if len(args) < 1 { 310 | return ErrNoSuchGroup 311 | } 312 | 313 | group, err := s.backend.GetGroup(args[0]) 314 | if err != nil { 315 | return err 316 | } 317 | 318 | s.group = group 319 | 320 | c.PrintfLine("211 %d %d %d %s", 321 | group.Count, group.Low, group.High, group.Name) 322 | return nil 323 | } 324 | 325 | func (s *session) getArticle(args []string) (*nntp.Article, error) { 326 | if s.group == nil { 327 | return nil, ErrNoGroupSelected 328 | } 329 | return s.backend.GetArticle(s.group, args[0]) 330 | } 331 | 332 | /* 333 | Syntax 334 | HEAD message-id 335 | HEAD number 336 | HEAD 337 | 338 | 339 | First form (message-id specified) 340 | 221 0|n message-id Headers follow (multi-line) 341 | 430 No article with that message-id 342 | 343 | Second form (article number specified) 344 | 221 n message-id Headers follow (multi-line) 345 | 412 No newsgroup selected 346 | 423 No article with that number 347 | 348 | Third form (current article number used) 349 | 221 n message-id Headers follow (multi-line) 350 | 412 No newsgroup selected 351 | 420 Current article number is invalid 352 | */ 353 | 354 | func handleHead(args []string, s *session, c *textproto.Conn) error { 355 | article, err := s.getArticle(args) 356 | if err != nil { 357 | return err 358 | } 359 | c.PrintfLine("221 1 %s", article.MessageID()) 360 | dw := c.DotWriter() 361 | defer dw.Close() 362 | for k, v := range article.Header { 363 | fmt.Fprintf(dw, "%s: %s\r\n", k, v[0]) 364 | } 365 | return nil 366 | } 367 | 368 | /* 369 | Syntax 370 | BODY message-id 371 | BODY number 372 | BODY 373 | 374 | Responses 375 | 376 | First form (message-id specified) 377 | 222 0|n message-id Body follows (multi-line) 378 | 430 No article with that message-id 379 | 380 | Second form (article number specified) 381 | 222 n message-id Body follows (multi-line) 382 | 412 No newsgroup selected 383 | 423 No article with that number 384 | 385 | Third form (current article number used) 386 | 222 n message-id Body follows (multi-line) 387 | 412 No newsgroup selected 388 | 420 Current article number is invalid 389 | 390 | Parameters 391 | number Requested article number 392 | n Returned article number 393 | message-id Article message-id 394 | */ 395 | 396 | func handleBody(args []string, s *session, c *textproto.Conn) error { 397 | article, err := s.getArticle(args) 398 | if err != nil { 399 | return err 400 | } 401 | c.PrintfLine("222 1 %s", article.MessageID()) 402 | dw := c.DotWriter() 403 | defer dw.Close() 404 | _, err = io.Copy(dw, article.Body) 405 | return err 406 | } 407 | 408 | /* 409 | Syntax 410 | ARTICLE message-id 411 | ARTICLE number 412 | ARTICLE 413 | 414 | Responses 415 | 416 | First form (message-id specified) 417 | 220 0|n message-id Article follows (multi-line) 418 | 430 No article with that message-id 419 | 420 | Second form (article number specified) 421 | 220 n message-id Article follows (multi-line) 422 | 412 No newsgroup selected 423 | 423 No article with that number 424 | 425 | Third form (current article number used) 426 | 220 n message-id Article follows (multi-line) 427 | 412 No newsgroup selected 428 | 420 Current article number is invalid 429 | 430 | Parameters 431 | number Requested article number 432 | n Returned article number 433 | message-id Article message-id 434 | */ 435 | 436 | func handleArticle(args []string, s *session, c *textproto.Conn) error { 437 | article, err := s.getArticle(args) 438 | if err != nil { 439 | return err 440 | } 441 | c.PrintfLine("220 1 %s", article.MessageID()) 442 | dw := c.DotWriter() 443 | defer dw.Close() 444 | 445 | for k, v := range article.Header { 446 | fmt.Fprintf(dw, "%s: %s\r\n", k, v[0]) 447 | } 448 | 449 | fmt.Fprintln(dw, "") 450 | 451 | _, err = io.Copy(dw, article.Body) 452 | return err 453 | } 454 | 455 | /* 456 | Syntax 457 | POST 458 | 459 | Responses 460 | 461 | Initial responses 462 | 340 Send article to be posted 463 | 440 Posting not permitted 464 | 465 | Subsequent responses 466 | 240 Article received OK 467 | 441 Posting failed 468 | */ 469 | 470 | func handlePost(args []string, s *session, c *textproto.Conn) error { 471 | if !s.backend.AllowPost() { 472 | return ErrPostingNotPermitted 473 | } 474 | 475 | c.PrintfLine("340 Go ahead") 476 | var err error 477 | var article nntp.Article 478 | article.Header, err = c.ReadMIMEHeader() 479 | if err != nil { 480 | return ErrPostingFailed 481 | } 482 | article.Body = c.DotReader() 483 | err = s.backend.Post(&article) 484 | if err != nil { 485 | return err 486 | } 487 | c.PrintfLine("240 article received OK") 488 | return nil 489 | } 490 | 491 | func handleIHave(args []string, s *session, c *textproto.Conn) error { 492 | if !s.backend.AllowPost() { 493 | return ErrNotWanted 494 | } 495 | 496 | // XXX: See if we have it. 497 | article, err := s.backend.GetArticle(nil, args[0]) 498 | if article != nil { 499 | return ErrNotWanted 500 | } 501 | 502 | c.PrintfLine("335 send it") 503 | article = &nntp.Article{} 504 | article.Header, err = c.ReadMIMEHeader() 505 | if err != nil { 506 | return ErrPostingFailed 507 | } 508 | article.Body = c.DotReader() 509 | err = s.backend.Post(article) 510 | if err != nil { 511 | return err 512 | } 513 | c.PrintfLine("235 article received OK") 514 | return nil 515 | } 516 | 517 | func handleCap(args []string, s *session, c *textproto.Conn) error { 518 | c.PrintfLine("101 Capability list:") 519 | dw := c.DotWriter() 520 | defer dw.Close() 521 | 522 | fmt.Fprintf(dw, "VERSION 2\n") 523 | fmt.Fprintf(dw, "READER\n") 524 | if s.backend.AllowPost() { 525 | fmt.Fprintf(dw, "POST\n") 526 | fmt.Fprintf(dw, "IHAVE\n") 527 | } 528 | fmt.Fprintf(dw, "OVER\n") 529 | fmt.Fprintf(dw, "XOVER\n") 530 | fmt.Fprintf(dw, "LIST ACTIVE NEWSGROUPS OVERVIEW.FMT\n") 531 | return nil 532 | } 533 | 534 | func handleMode(args []string, s *session, c *textproto.Conn) error { 535 | if s.backend.AllowPost() { 536 | c.PrintfLine("200 Posting allowed") 537 | } else { 538 | c.PrintfLine("201 Posting prohibited") 539 | } 540 | return nil 541 | } 542 | 543 | func handleAuthInfo(args []string, s *session, c *textproto.Conn) error { 544 | if len(args) < 2 { 545 | return ErrSyntax 546 | } 547 | if strings.ToLower(args[0]) != "user" { 548 | return ErrSyntax 549 | } 550 | 551 | if s.backend.Authorized() { 552 | return c.PrintfLine("250 authenticated") 553 | } 554 | 555 | c.PrintfLine("350 Continue") 556 | a, err := c.ReadLine() 557 | parts := strings.SplitN(a, " ", 3) 558 | if strings.ToLower(parts[0]) != "authinfo" || strings.ToLower(parts[1]) != "pass" { 559 | return ErrSyntax 560 | } 561 | b, err := s.backend.Authenticate(args[1], parts[2]) 562 | if err == nil { 563 | c.PrintfLine("250 authenticated") 564 | if b != nil { 565 | s.backend = b 566 | } 567 | } 568 | return err 569 | } 570 | --------------------------------------------------------------------------------