├── LICENSE ├── README.md ├── date.go ├── nntp.go └── nntp_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2009 The nntp-go Authors. All rights reserved. 2 | // 3 | // Redistribution and use in source and binary forms, with or without 4 | // modification, are permitted provided that the following conditions are 5 | // met: 6 | // 7 | // * Redistributions of source code must retain the above copyright 8 | // notice, this list of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above 10 | // copyright notice, this list of conditions and the following disclaimer 11 | // in the documentation and/or other materials provided with the 12 | // distribution. 13 | // * Neither the name of Google Inc. nor the names of its 14 | // contributors may be used to endorse or promote products derived from 15 | // this software without specific prior written permission. 16 | // 17 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | // 29 | // Subject to the terms and conditions of this License, Google hereby 30 | // grants to You a perpetual, worldwide, non-exclusive, no-charge, 31 | // royalty-free, irrevocable (except as stated in this section) patent 32 | // license to make, have made, use, offer to sell, sell, import, and 33 | // otherwise transfer this implementation of nntp-go, where such license 34 | // applies only to those patent claims licensable by Google that are 35 | // necessarily infringed by use of this implementation of nntp-go. If You 36 | // institute patent litigation against any entity (including a 37 | // cross-claim or counterclaim in a lawsuit) alleging that this 38 | // implementation of nntp-go or a Contribution incorporated within this 39 | // implementation of nntp-go constitutes direct or contributory patent 40 | // infringement, then any patent licenses granted to You under this 41 | // License for this implementation of nntp-go shall terminate as of the date 42 | // such litigation is filed. 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nntp.go 2 | ======= 3 | 4 | An NNTP (news) Client package for go (golang). Forked from [nntp-go](http://code.google.com/p/nntp-go/) to bring it up to date. 5 | 6 | Example 7 | ------- 8 | 9 | ```go 10 | // connect to news server 11 | conn, err := nntp.Dial("tcp", "news.example.com:119") 12 | if err != nil { 13 | log.Fatalf("connection failed: %v", err) 14 | } 15 | 16 | // auth 17 | if err := conn.Authenticate("user", "pass"); err != nil { 18 | log.Fatalf("Could not authenticate") 19 | } 20 | 21 | // connect to a news group 22 | grp := "alt.binaries.pictures" 23 | _, l, _, err := conn.Group(grp) 24 | if err != nil { 25 | log.Fatalf("Could not connect to group %s: %v %d", grp, err, l) 26 | } 27 | 28 | // fetch an article 29 | id := "<4c1c18ec$0$8490$c3e8da3@news.astraweb.com>" 30 | article, err := conn.Article(id) 31 | if err != nil { 32 | log.Fatalf("Could not fetch article %s: %v", id, err) 33 | } 34 | 35 | // read the article contents 36 | body, err := ioutil.ReadAll(article.Body) 37 | if err != nil { 38 | log.Fatalf("error reading reader: %v", err) 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /date.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // RFC5322 date parsing. Copied from net/mail Go standard 6 | // library package. 7 | package nntp 8 | 9 | import "errors" 10 | import "time" 11 | 12 | // Layouts suitable for passing to time.Parse. 13 | // These are tried in order. 14 | var dateLayouts []string 15 | 16 | func init() { 17 | // Generate layouts based on RFC 5322, section 3.3. 18 | 19 | dows := [...]string{"", "Mon, "} // day-of-week 20 | days := [...]string{"2", "02"} // day = 1*2DIGIT 21 | years := [...]string{"2006", "06"} // year = 4*DIGIT / 2*DIGIT 22 | seconds := [...]string{":05", ""} // second 23 | // "-0700 (MST)" is not in RFC 5322, but is common. 24 | zones := [...]string{"-0700", "MST", "-0700 (MST)"} // zone = (("+" / "-") 4DIGIT) / "GMT" / ... 25 | 26 | for _, dow := range dows { 27 | for _, day := range days { 28 | for _, year := range years { 29 | for _, second := range seconds { 30 | for _, zone := range zones { 31 | s := dow + day + " Jan " + year + " 15:04" + second + " " + zone 32 | dateLayouts = append(dateLayouts, s) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | func parseDate(date string) (time.Time, error) { 41 | for _, layout := range dateLayouts { 42 | t, err := time.Parse(layout, date) 43 | if err == nil { 44 | return t, nil 45 | } 46 | } 47 | return time.Time{}, errors.New("date cannot be parsed") 48 | } 49 | -------------------------------------------------------------------------------- /nntp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // The nntp package implements a client for the news protocol NNTP, 6 | // as defined in RFC 3977. 7 | package nntp 8 | 9 | import ( 10 | "bufio" 11 | "bytes" 12 | "crypto/tls" 13 | "fmt" 14 | "io" 15 | "io/ioutil" 16 | "net" 17 | "net/http" 18 | "sort" 19 | "strconv" 20 | "strings" 21 | "time" 22 | ) 23 | 24 | // timeFormatNew is the NNTP time format string for NEWNEWS / NEWGROUPS 25 | const timeFormatNew = "20060102 150405" 26 | 27 | // timeFormatDate is the NNTP time format string for responses to the DATE command 28 | const timeFormatDate = "20060102150405" 29 | 30 | // An Error represents an error response from an NNTP server. 31 | type Error struct { 32 | Code uint 33 | Msg string 34 | } 35 | 36 | // A ProtocolError represents responses from an NNTP server 37 | // that seem incorrect for NNTP. 38 | type ProtocolError string 39 | 40 | // A Conn represents a connection to an NNTP server. The connection with 41 | // an NNTP server is stateful; it keeps track of what group you have 42 | // selected, if any, and (if you have a group selected) which article is 43 | // current, next, or previous. 44 | // 45 | // Some methods that return information about a specific message take 46 | // either a message-id, which is global across all NNTP servers, groups, 47 | // and messages, or a message-number, which is an integer number that is 48 | // local to the NNTP session and currently selected group. 49 | // 50 | // For all methods that return an io.Reader (or an *Article, which contains 51 | // an io.Reader), that io.Reader is only valid until the next call to a 52 | // method of Conn. 53 | type Conn struct { 54 | conn io.WriteCloser 55 | r *bufio.Reader 56 | br *bodyReader 57 | close bool 58 | } 59 | 60 | // A Group gives information about a single news group on the server. 61 | type Group struct { 62 | Name string 63 | // High and low message-numbers 64 | High, Low int 65 | // Status indicates if general posting is allowed -- 66 | // typical values are "y", "n", or "m". 67 | Status string 68 | } 69 | 70 | // An Article represents an NNTP article. 71 | type Article struct { 72 | Header map[string][]string 73 | Body io.Reader 74 | } 75 | 76 | // A bodyReader satisfies reads by reading from the connection 77 | // until it finds a line containing just . 78 | type bodyReader struct { 79 | c *Conn 80 | eof bool 81 | buf *bytes.Buffer 82 | } 83 | 84 | var dotnl = []byte(".\n") 85 | var dotdot = []byte("..") 86 | 87 | func (r *bodyReader) Read(p []byte) (n int, err error) { 88 | if r.eof { 89 | return 0, io.EOF 90 | } 91 | if r.buf == nil { 92 | r.buf = &bytes.Buffer{} 93 | } 94 | if r.buf.Len() == 0 { 95 | b, err := r.c.r.ReadBytes('\n') 96 | if err != nil { 97 | return 0, err 98 | } 99 | // canonicalize newlines 100 | if b[len(b)-2] == '\r' { // crlf->lf 101 | b = b[0 : len(b)-1] 102 | b[len(b)-1] = '\n' 103 | } 104 | // stop on . 105 | if bytes.Equal(b, dotnl) { 106 | r.eof = true 107 | return 0, io.EOF 108 | } 109 | // unescape leading .. 110 | if bytes.HasPrefix(b, dotdot) { 111 | b = b[1:] 112 | } 113 | r.buf.Write(b) 114 | } 115 | n, _ = r.buf.Read(p) 116 | return 117 | } 118 | 119 | func (r *bodyReader) discard() error { 120 | _, err := ioutil.ReadAll(r) 121 | return err 122 | } 123 | 124 | // articleReader satisfies reads by dumping out an article's headers 125 | // and body. 126 | type articleReader struct { 127 | a *Article 128 | headerdone bool 129 | headerbuf *bytes.Buffer 130 | } 131 | 132 | func (r *articleReader) Read(p []byte) (n int, err error) { 133 | if r.headerbuf == nil { 134 | buf := new(bytes.Buffer) 135 | for k, fv := range r.a.Header { 136 | for _, v := range fv { 137 | fmt.Fprintf(buf, "%s: %s\n", k, v) 138 | } 139 | } 140 | if r.a.Body != nil { 141 | fmt.Fprintf(buf, "\n") 142 | } 143 | r.headerbuf = buf 144 | } 145 | if !r.headerdone { 146 | n, err = r.headerbuf.Read(p) 147 | if err == io.EOF { 148 | err = nil 149 | r.headerdone = true 150 | } 151 | if n > 0 { 152 | return 153 | } 154 | } 155 | if r.a.Body != nil { 156 | n, err = r.a.Body.Read(p) 157 | if err == io.EOF { 158 | r.a.Body = nil 159 | } 160 | return 161 | } 162 | return 0, io.EOF 163 | } 164 | 165 | func (a *Article) String() string { 166 | id, ok := a.Header["Message-Id"] 167 | if !ok { 168 | return "[NNTP article]" 169 | } 170 | return fmt.Sprintf("[NNTP article %s]", id[0]) 171 | } 172 | 173 | func (a *Article) WriteTo(w io.Writer) (int64, error) { 174 | return io.Copy(w, &articleReader{a: a}) 175 | } 176 | 177 | func (p ProtocolError) Error() string { 178 | return string(p) 179 | } 180 | 181 | func (e Error) Error() string { 182 | return fmt.Sprintf("%03d %s", e.Code, e.Msg) 183 | } 184 | 185 | func maybeId(cmd, id string) string { 186 | if len(id) > 0 { 187 | return cmd + " " + id 188 | } 189 | return cmd 190 | } 191 | 192 | func newConn(c net.Conn) (res *Conn, err error) { 193 | res = &Conn{ 194 | conn: c, 195 | r: bufio.NewReaderSize(c, 4096), 196 | } 197 | 198 | if _, err = res.r.ReadString('\n'); err != nil { 199 | return 200 | } 201 | 202 | return 203 | } 204 | 205 | // Dial connects to an NNTP server. 206 | // The network and addr are passed to net.Dial to 207 | // make the connection. 208 | // 209 | // Example: 210 | // conn, err := nntp.Dial("tcp", "my.news:nntp") 211 | // 212 | func Dial(network, addr string) (*Conn, error) { 213 | c, err := net.Dial(network, addr) 214 | if err != nil { 215 | return nil, err 216 | } 217 | return newConn(c) 218 | } 219 | 220 | // Same as Dial but handles TLS connections 221 | func DialTLS(network, addr string, config *tls.Config) (*Conn, error) { 222 | // dial 223 | c, err := tls.Dial(network, addr, config) 224 | if err != nil { 225 | return nil, err 226 | } 227 | // return nntp Conn 228 | return newConn(c) 229 | } 230 | 231 | func (c *Conn) body() io.Reader { 232 | c.br = &bodyReader{c: c} 233 | return c.br 234 | } 235 | 236 | // readStrings reads a list of strings from the NNTP connection, 237 | // stopping at a line containing only a . (Convenience method for 238 | // LIST, etc.) 239 | func (c *Conn) readStrings() ([]string, error) { 240 | var sv []string 241 | for { 242 | line, err := c.r.ReadString('\n') 243 | if err != nil { 244 | return nil, err 245 | } 246 | if strings.HasSuffix(line, "\r\n") { 247 | line = line[0 : len(line)-2] 248 | } else if strings.HasSuffix(line, "\n") { 249 | line = line[0 : len(line)-1] 250 | } 251 | if line == "." { 252 | break 253 | } 254 | sv = append(sv, line) 255 | } 256 | return []string(sv), nil 257 | } 258 | 259 | // Authenticate logs in to the NNTP server. 260 | // It only sends the password if the server requires one. 261 | func (c *Conn) Authenticate(username, password string) error { 262 | code, _, err := c.cmd(2, "AUTHINFO USER %s", username) 263 | if code/100 == 3 { 264 | _, _, err = c.cmd(2, "AUTHINFO PASS %s", password) 265 | } 266 | return err 267 | } 268 | 269 | // cmd executes an NNTP command: 270 | // It sends the command given by the format and arguments, and then 271 | // reads the response line. If expectCode > 0, the status code on the 272 | // response line must match it. 1 digit expectCodes only check the first 273 | // digit of the status code, etc. 274 | func (c *Conn) cmd(expectCode uint, format string, args ...interface{}) (code uint, line string, err error) { 275 | if c.close { 276 | return 0, "", ProtocolError("connection closed") 277 | } 278 | if c.br != nil { 279 | if err := c.br.discard(); err != nil { 280 | return 0, "", err 281 | } 282 | c.br = nil 283 | } 284 | if _, err := fmt.Fprintf(c.conn, format+"\r\n", args...); err != nil { 285 | return 0, "", err 286 | } 287 | line, err = c.r.ReadString('\n') 288 | if err != nil { 289 | return 0, "", err 290 | } 291 | line = strings.TrimSpace(line) 292 | if len(line) < 4 || line[3] != ' ' { 293 | return 0, "", ProtocolError("short response: " + line) 294 | } 295 | i, err := strconv.ParseUint(line[0:3], 10, 0) 296 | if err != nil { 297 | return 0, "", ProtocolError("invalid response code: " + line) 298 | } 299 | code = uint(i) 300 | line = line[4:] 301 | if 1 <= expectCode && expectCode < 10 && code/100 != expectCode || 302 | 10 <= expectCode && expectCode < 100 && code/10 != expectCode || 303 | 100 <= expectCode && expectCode < 1000 && code != expectCode { 304 | err = Error{code, line} 305 | } 306 | return 307 | } 308 | 309 | // ModeReader switches the NNTP server to "reader" mode, if it 310 | // is a mode-switching server. 311 | func (c *Conn) ModeReader() error { 312 | _, _, err := c.cmd(20, "MODE READER") 313 | return err 314 | } 315 | 316 | // NewGroups returns a list of groups added since the given time. 317 | func (c *Conn) NewGroups(since time.Time) ([]*Group, error) { 318 | if _, _, err := c.cmd(231, "NEWGROUPS %s GMT", since.Format(timeFormatNew)); err != nil { 319 | return nil, err 320 | } 321 | return c.readGroups() 322 | } 323 | 324 | func (c *Conn) readGroups() ([]*Group, error) { 325 | lines, err := c.readStrings() 326 | if err != nil { 327 | return nil, err 328 | } 329 | return parseGroups(lines) 330 | } 331 | 332 | // NewNews returns a list of the IDs of articles posted 333 | // to the given group since the given time. 334 | func (c *Conn) NewNews(group string, since time.Time) ([]string, error) { 335 | if _, _, err := c.cmd(230, "NEWNEWS %s %s GMT", group, since.Format(timeFormatNew)); err != nil { 336 | return nil, err 337 | } 338 | 339 | id, err := c.readStrings() 340 | if err != nil { 341 | return nil, err 342 | } 343 | 344 | sort.Strings(id) 345 | w := 0 346 | for r, s := range id { 347 | if r == 0 || id[r-1] != s { 348 | id[w] = s 349 | w++ 350 | } 351 | } 352 | id = id[0:w] 353 | 354 | return id, nil 355 | } 356 | 357 | // Overview of a message returned by OVER command. 358 | type MessageOverview struct { 359 | MessageNumber int // Message number in the group 360 | Subject string // Subject header value. Empty if the header is missing. 361 | From string // From header value. Empty is the header is missing. 362 | Date time.Time // Parsed Date header value. Zero if the header is missing or unparseable. 363 | MessageId string // Message-Id header value. Empty is the header is missing. 364 | References []string // Message-Id's of referenced messages (References header value, split on spaces). Empty if the header is missing. 365 | Bytes int // Message size in bytes, called :bytes metadata item in RFC3977. 366 | Lines int // Message size in lines, called :lines metadata item in RFC3977. 367 | Extra []string // Any additional fields returned by the server. 368 | } 369 | 370 | // Overview returns overviews of all messages in the current group with message number between 371 | // begin and end, inclusive. 372 | func (c *Conn) Overview(begin, end int) ([]MessageOverview, error) { 373 | if _, _, err := c.cmd(224, "OVER %d-%d", begin, end); err != nil { 374 | return nil, err 375 | } 376 | 377 | lines, err := c.readStrings() 378 | if err != nil { 379 | return nil, err 380 | } 381 | 382 | result := make([]MessageOverview, 0, len(lines)) 383 | for _, line := range lines { 384 | overview := MessageOverview{} 385 | ss := strings.SplitN(strings.TrimSpace(line), "\t", 9) 386 | if len(ss) < 8 { 387 | return nil, ProtocolError("short header listing line: " + line + strconv.Itoa(len(ss))) 388 | } 389 | overview.MessageNumber, err = strconv.Atoi(ss[0]) 390 | if err != nil { 391 | return nil, ProtocolError("bad message number '" + ss[0] + "' in line: " + line) 392 | } 393 | overview.Subject = ss[1] 394 | overview.From = ss[2] 395 | overview.Date, err = parseDate(ss[3]) 396 | if err != nil { 397 | // Inability to parse date is not fatal: the field in the message may be broken or missing. 398 | overview.Date = time.Time{} 399 | } 400 | overview.MessageId = ss[4] 401 | overview.References = strings.Split(ss[5], " ") // Message-Id's contain no spaces, so this is safe. 402 | overview.Bytes, err = strconv.Atoi(ss[6]) 403 | if err != nil { 404 | return nil, ProtocolError("bad byte count '" + ss[6] + "'in line:" + line) 405 | } 406 | overview.Lines, err = strconv.Atoi(ss[7]) 407 | if err != nil { 408 | return nil, ProtocolError("bad line count '" + ss[7] + "'in line:" + line) 409 | } 410 | overview.Extra = append([]string{}, ss[8:]...) 411 | result = append(result, overview) 412 | } 413 | return result, nil 414 | } 415 | 416 | // parseGroups is used to parse a list of group states. 417 | func parseGroups(lines []string) ([]*Group, error) { 418 | res := make([]*Group, 0) 419 | for _, line := range lines { 420 | ss := strings.SplitN(strings.TrimSpace(line), " ", 4) 421 | if len(ss) < 4 { 422 | return nil, ProtocolError("short group info line: " + line) 423 | } 424 | high, err := strconv.Atoi(ss[1]) 425 | if err != nil { 426 | return nil, ProtocolError("bad number in line: " + line) 427 | } 428 | low, err := strconv.Atoi(ss[2]) 429 | if err != nil { 430 | return nil, ProtocolError("bad number in line: " + line) 431 | } 432 | res = append(res, &Group{ss[0], high, low, ss[3]}) 433 | } 434 | return res, nil 435 | } 436 | 437 | // Capabilities returns a list of features this server performs. 438 | // Not all servers support capabilities. 439 | func (c *Conn) Capabilities() ([]string, error) { 440 | if _, _, err := c.cmd(101, "CAPABILITIES"); err != nil { 441 | return nil, err 442 | } 443 | return c.readStrings() 444 | } 445 | 446 | // Date returns the current time on the server. 447 | // Typically the time is later passed to NewGroups or NewNews. 448 | func (c *Conn) Date() (time.Time, error) { 449 | _, line, err := c.cmd(111, "DATE") 450 | if err != nil { 451 | return time.Time{}, err 452 | } 453 | t, err := time.Parse(timeFormatDate, line) 454 | if err != nil { 455 | return time.Time{}, ProtocolError("invalid time: " + line) 456 | } 457 | return t, nil 458 | } 459 | 460 | // List returns a list of groups present on the server. 461 | // Valid forms are: 462 | // 463 | // List() - return active groups 464 | // List(keyword) - return different kinds of information about groups 465 | // List(keyword, pattern) - filter groups against a glob-like pattern called a wildmat 466 | // 467 | func (c *Conn) List(a ...string) ([]string, error) { 468 | if len(a) > 2 { 469 | return nil, ProtocolError("List only takes up to 2 arguments") 470 | } 471 | cmd := "LIST" 472 | if len(a) > 0 { 473 | cmd += " " + a[0] 474 | if len(a) > 1 { 475 | cmd += " " + a[1] 476 | } 477 | } 478 | if _, _, err := c.cmd(215, cmd); err != nil { 479 | return nil, err 480 | } 481 | return c.readStrings() 482 | } 483 | 484 | // Group changes the current group. 485 | func (c *Conn) Group(group string) (number, low, high int, err error) { 486 | _, line, err := c.cmd(211, "GROUP %s", group) 487 | if err != nil { 488 | return 489 | } 490 | 491 | ss := strings.SplitN(line, " ", 4) // intentional -- we ignore optional message 492 | if len(ss) < 3 { 493 | err = ProtocolError("bad group response: " + line) 494 | return 495 | } 496 | 497 | var n [3]int 498 | for i, _ := range n { 499 | c, e := strconv.Atoi(ss[i]) 500 | if e != nil { 501 | err = ProtocolError("bad group response: " + line) 502 | return 503 | } 504 | n[i] = c 505 | } 506 | number, low, high = n[0], n[1], n[2] 507 | return 508 | } 509 | 510 | // Help returns the server's help text. 511 | func (c *Conn) Help() (io.Reader, error) { 512 | if _, _, err := c.cmd(100, "HELP"); err != nil { 513 | return nil, err 514 | } 515 | return c.body(), nil 516 | } 517 | 518 | // nextLastStat performs the work for NEXT, LAST, and STAT. 519 | func (c *Conn) nextLastStat(cmd, id string) (string, string, error) { 520 | _, line, err := c.cmd(223, maybeId(cmd, id)) 521 | if err != nil { 522 | return "", "", err 523 | } 524 | ss := strings.SplitN(line, " ", 3) // optional comment ignored 525 | if len(ss) < 2 { 526 | return "", "", ProtocolError("Bad response to " + cmd + ": " + line) 527 | } 528 | return ss[0], ss[1], nil 529 | } 530 | 531 | // Stat looks up the message with the given id and returns its 532 | // message number in the current group, and vice versa. 533 | // The returned message number can be "0" if the current group 534 | // isn't one of the groups the message was posted to. 535 | func (c *Conn) Stat(id string) (number, msgid string, err error) { 536 | return c.nextLastStat("STAT", id) 537 | } 538 | 539 | // Last selects the previous article, returning its message number and id. 540 | func (c *Conn) Last() (number, msgid string, err error) { 541 | return c.nextLastStat("LAST", "") 542 | } 543 | 544 | // Next selects the next article, returning its message number and id. 545 | func (c *Conn) Next() (number, msgid string, err error) { 546 | return c.nextLastStat("NEXT", "") 547 | } 548 | 549 | // ArticleText returns the article named by id as an io.Reader. 550 | // The article is in plain text format, not NNTP wire format. 551 | func (c *Conn) ArticleText(id string) (io.Reader, error) { 552 | if _, _, err := c.cmd(220, maybeId("ARTICLE", id)); err != nil { 553 | return nil, err 554 | } 555 | return c.body(), nil 556 | } 557 | 558 | // Article returns the article named by id as an *Article. 559 | func (c *Conn) Article(id string) (*Article, error) { 560 | if _, _, err := c.cmd(220, maybeId("ARTICLE", id)); err != nil { 561 | return nil, err 562 | } 563 | r := bufio.NewReader(c.body()) 564 | res, err := c.readHeader(r) 565 | if err != nil { 566 | return nil, err 567 | } 568 | res.Body = r 569 | return res, nil 570 | } 571 | 572 | // HeadText returns the header for the article named by id as an io.Reader. 573 | // The article is in plain text format, not NNTP wire format. 574 | func (c *Conn) HeadText(id string) (io.Reader, error) { 575 | if _, _, err := c.cmd(221, maybeId("HEAD", id)); err != nil { 576 | return nil, err 577 | } 578 | return c.body(), nil 579 | } 580 | 581 | // Head returns the header for the article named by id as an *Article. 582 | // The Body field in the Article is nil. 583 | func (c *Conn) Head(id string) (*Article, error) { 584 | if _, _, err := c.cmd(221, maybeId("HEAD", id)); err != nil { 585 | return nil, err 586 | } 587 | return c.readHeader(bufio.NewReader(c.body())) 588 | } 589 | 590 | // Body returns the body for the article named by id as an io.Reader. 591 | func (c *Conn) Body(id string) (io.Reader, error) { 592 | if _, _, err := c.cmd(222, maybeId("BODY", id)); err != nil { 593 | return nil, err 594 | } 595 | return c.body(), nil 596 | } 597 | 598 | // RawPost reads a text-formatted article from r and posts it to the server. 599 | func (c *Conn) RawPost(r io.Reader) error { 600 | if _, _, err := c.cmd(3, "POST"); err != nil { 601 | return err 602 | } 603 | br := bufio.NewReader(r) 604 | eof := false 605 | for { 606 | line, err := br.ReadString('\n') 607 | if err == io.EOF { 608 | eof = true 609 | } else if err != nil { 610 | return err 611 | } 612 | if eof && len(line) == 0 { 613 | break 614 | } 615 | if strings.HasSuffix(line, "\n") { 616 | line = line[0 : len(line)-1] 617 | } 618 | var prefix string 619 | if strings.HasPrefix(line, ".") { 620 | prefix = "." 621 | } 622 | _, err = fmt.Fprintf(c.conn, "%s%s\r\n", prefix, line) 623 | if err != nil { 624 | return err 625 | } 626 | if eof { 627 | break 628 | } 629 | } 630 | 631 | if _, _, err := c.cmd(240, "."); err != nil { 632 | return err 633 | } 634 | return nil 635 | } 636 | 637 | // Post posts an article to the server. 638 | func (c *Conn) Post(a *Article) error { 639 | return c.RawPost(&articleReader{a: a}) 640 | } 641 | 642 | // Quit sends the QUIT command and closes the connection to the server. 643 | func (c *Conn) Quit() error { 644 | _, _, err := c.cmd(0, "QUIT") 645 | c.conn.Close() 646 | c.close = true 647 | return err 648 | } 649 | 650 | // Functions after this point are mostly copy-pasted from http 651 | // (though with some modifications). They should be factored out to 652 | // a common library. 653 | 654 | // Read a line of bytes (up to \n) from b. 655 | // Give up if the line exceeds maxLineLength. 656 | // The returned bytes are a pointer into storage in 657 | // the bufio, so they are only valid until the next bufio read. 658 | func readLineBytes(b *bufio.Reader) (p []byte, err error) { 659 | if p, err = b.ReadSlice('\n'); err != nil { 660 | // We always know when EOF is coming. 661 | // If the caller asked for a line, there should be a line. 662 | if err == io.EOF { 663 | err = io.ErrUnexpectedEOF 664 | } 665 | return nil, err 666 | } 667 | 668 | // Chop off trailing white space. 669 | var i int 670 | for i = len(p); i > 0; i-- { 671 | if c := p[i-1]; c != ' ' && c != '\r' && c != '\t' && c != '\n' { 672 | break 673 | } 674 | } 675 | return p[0:i], nil 676 | } 677 | 678 | var colon = []byte{':'} 679 | 680 | // Read a key/value pair from b. 681 | // A key/value has the form Key: Value\r\n 682 | // and the Value can continue on multiple lines if each continuation line 683 | // starts with a space/tab. 684 | func readKeyValue(b *bufio.Reader) (key, value string, err error) { 685 | line, e := readLineBytes(b) 686 | if e == io.ErrUnexpectedEOF { 687 | return "", "", nil 688 | } else if e != nil { 689 | return "", "", e 690 | } 691 | if len(line) == 0 { 692 | return "", "", nil 693 | } 694 | 695 | // Scan first line for colon. 696 | i := bytes.Index(line, colon) 697 | if i < 0 { 698 | goto Malformed 699 | } 700 | 701 | key = string(line[0:i]) 702 | if strings.Index(key, " ") >= 0 { 703 | // Key field has space - no good. 704 | goto Malformed 705 | } 706 | 707 | // Skip initial space before value. 708 | for i++; i < len(line); i++ { 709 | if line[i] != ' ' && line[i] != '\t' { 710 | break 711 | } 712 | } 713 | value = string(line[i:]) 714 | 715 | // Look for extension lines, which must begin with space. 716 | for { 717 | c, e := b.ReadByte() 718 | if c != ' ' && c != '\t' { 719 | if e != io.EOF { 720 | b.UnreadByte() 721 | } 722 | break 723 | } 724 | 725 | // Eat leading space. 726 | for c == ' ' || c == '\t' { 727 | if c, e = b.ReadByte(); e != nil { 728 | if e == io.EOF { 729 | e = io.ErrUnexpectedEOF 730 | } 731 | return "", "", e 732 | } 733 | } 734 | b.UnreadByte() 735 | 736 | // Read the rest of the line and add to value. 737 | if line, e = readLineBytes(b); e != nil { 738 | return "", "", e 739 | } 740 | value += " " + string(line) 741 | } 742 | return key, value, nil 743 | 744 | Malformed: 745 | return "", "", ProtocolError("malformed header line: " + string(line)) 746 | } 747 | 748 | // Internal. Parses headers in NNTP articles. Most of this is stolen from the http package, 749 | // and it should probably be split out into a generic RFC822 header-parsing package. 750 | func (c *Conn) readHeader(r *bufio.Reader) (res *Article, err error) { 751 | res = new(Article) 752 | res.Header = make(map[string][]string) 753 | for { 754 | var key, value string 755 | if key, value, err = readKeyValue(r); err != nil { 756 | return nil, err 757 | } 758 | if key == "" { 759 | break 760 | } 761 | key = http.CanonicalHeaderKey(key) 762 | // RFC 3977 says nothing about duplicate keys' values being equivalent to 763 | // a single key joined with commas, so we keep all values seperate. 764 | oldvalue, present := res.Header[key] 765 | if present { 766 | sv := make([]string, 0) 767 | sv = append(sv, oldvalue...) 768 | sv = append(sv, value) 769 | res.Header[key] = sv 770 | } else { 771 | res.Header[key] = []string{value} 772 | } 773 | } 774 | return res, nil 775 | } 776 | -------------------------------------------------------------------------------- /nntp_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package nntp 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "fmt" 11 | "io" 12 | "io/ioutil" 13 | "strings" 14 | "testing" 15 | "time" 16 | ) 17 | 18 | func TestSanityChecks(t *testing.T) { 19 | if _, err := Dial("", ""); err == nil { 20 | t.Fatal("Dial should require at least a destination address.") 21 | } 22 | } 23 | 24 | type faker struct { 25 | io.Writer 26 | } 27 | 28 | func (f faker) Close() error { 29 | return nil 30 | } 31 | 32 | func TestBasics(t *testing.T) { 33 | basicServer = strings.Join(strings.Split(basicServer, "\n"), "\r\n") 34 | basicClient = strings.Join(strings.Split(basicClient, "\n"), "\r\n") 35 | 36 | var cmdbuf bytes.Buffer 37 | var fake faker 38 | fake.Writer = &cmdbuf 39 | 40 | conn := &Conn{conn: fake, r: bufio.NewReader(strings.NewReader(basicServer))} 41 | 42 | // Test some global commands that don't take arguments 43 | if _, err := conn.Capabilities(); err != nil { 44 | t.Fatal("should be able to request CAPABILITIES after connecting: " + err.Error()) 45 | } 46 | 47 | _, err := conn.Date() 48 | if err != nil { 49 | t.Fatal("should be able to send DATE: " + err.Error()) 50 | } 51 | 52 | /* 53 | Test broken until time.Parse adds this format. 54 | cdate := time.UTC() 55 | if sdate.Year != cdate.Year || sdate.Month != cdate.Month || sdate.Day != cdate.Day { 56 | t.Fatal("DATE seems off, probably erroneous: " + sdate.String()) 57 | } 58 | */ 59 | 60 | // Test LIST (implicit ACTIVE) 61 | if _, err = conn.List(); err != nil { 62 | t.Fatal("LIST should work: " + err.Error()) 63 | } 64 | 65 | tt := time.Date(2010, time.March, 01, 00, 0, 0, 0, time.UTC) 66 | 67 | const grp = "gmane.comp.lang.go.general" 68 | _, l, h, err := conn.Group(grp) 69 | if err != nil { 70 | t.Fatal("Group shouldn't error: " + err.Error()) 71 | } 72 | 73 | // test STAT, NEXT, and LAST 74 | if _, _, err = conn.Stat(""); err != nil { 75 | t.Fatal("should be able to STAT after selecting a group: " + err.Error()) 76 | } 77 | if _, _, err = conn.Next(); err != nil { 78 | t.Fatal("should be able to NEXT after selecting a group: " + err.Error()) 79 | } 80 | if _, _, err = conn.Last(); err != nil { 81 | t.Fatal("should be able to LAST after a NEXT selecting a group: " + err.Error()) 82 | } 83 | 84 | // Can we grab articles? 85 | a, err := conn.Article(fmt.Sprintf("%d", l)) 86 | if err != nil { 87 | t.Fatal("should be able to fetch the low article: " + err.Error()) 88 | } 89 | body, err := ioutil.ReadAll(a.Body) 90 | if err != nil { 91 | t.Fatal("error reading reader: " + err.Error()) 92 | } 93 | 94 | // Test that the article body doesn't get mangled. 95 | expectedbody := `Blah, blah. 96 | .A single leading . 97 | Fin. 98 | ` 99 | if !bytes.Equal([]byte(expectedbody), body) { 100 | t.Fatalf("article body read incorrectly; got:\n%s\nExpected:\n%s", body, expectedbody) 101 | } 102 | 103 | // Test articleReader 104 | expectedart := `Message-Id: 105 | 106 | Body. 107 | ` 108 | a, err = conn.Article(fmt.Sprintf("%d", l+1)) 109 | if err != nil { 110 | t.Fatal("shouldn't error reading article low+1: " + err.Error()) 111 | } 112 | var abuf bytes.Buffer 113 | _, err = a.WriteTo(&abuf) 114 | if err != nil { 115 | t.Fatal("shouldn't error writing out article: " + err.Error()) 116 | } 117 | actualart := abuf.String() 118 | if actualart != expectedart { 119 | t.Fatalf("articleReader broke; got:\n%s\nExpected\n%s", actualart, expectedart) 120 | } 121 | 122 | // Just headers? 123 | if _, err = conn.Head(fmt.Sprintf("%d", h)); err != nil { 124 | t.Fatal("should be able to fetch the high article: " + err.Error()) 125 | } 126 | 127 | // Without an id? 128 | if _, err = conn.Head(""); err != nil { 129 | t.Fatal("should be able to fetch the selected article without specifying an id: " + err.Error()) 130 | } 131 | 132 | // How about bad articles? Do they error? 133 | if _, err = conn.Head(fmt.Sprintf("%d", l-1)); err == nil { 134 | t.Fatal("shouldn't be able to fetch articles lower than low") 135 | } 136 | if _, err = conn.Head(fmt.Sprintf("%d", h+1)); err == nil { 137 | t.Fatal("shouldn't be able to fetch articles higher than high") 138 | } 139 | 140 | // Just the body? 141 | r, err := conn.Body(fmt.Sprintf("%d", l)) 142 | if err != nil { 143 | t.Fatal("should be able to fetch the low article body" + err.Error()) 144 | } 145 | if _, err = ioutil.ReadAll(r); err != nil { 146 | t.Fatal("error reading reader: " + err.Error()) 147 | } 148 | 149 | if _, err = conn.NewNews(grp, tt); err != nil { 150 | t.Fatal("newnews should work: " + err.Error()) 151 | } 152 | 153 | // NewGroups 154 | if _, err = conn.NewGroups(tt); err != nil { 155 | t.Fatal("newgroups shouldn't error " + err.Error()) 156 | } 157 | 158 | // Overview 159 | overviews, err := conn.Overview(10, 11) 160 | if err != nil { 161 | t.Fatal("overview shouldn't error: " + err.Error()) 162 | } 163 | expectedOverviews := []MessageOverview{ 164 | MessageOverview{10, "Subject10", "Author ", time.Date(2003, 10, 18, 18, 0, 0, 0, time.FixedZone("", 1800)), "", []string{}, 1000, 9, []string{}}, 165 | MessageOverview{11, "Subject11", "", time.Date(2003, 10, 18, 19, 0, 0, 0, time.FixedZone("", 1800)), "", []string{"", ""}, 2000, 18, []string{"Extra stuff"}}, 166 | } 167 | 168 | if len(overviews) != len(expectedOverviews) { 169 | t.Fatalf("returned %d overviews, expected %d", len(overviews), len(expectedOverviews)) 170 | } 171 | 172 | for i, o := range overviews { 173 | if fmt.Sprint(o) != fmt.Sprint(expectedOverviews[i]) { 174 | t.Fatalf("in place of %dth overview expected %v, got %v", i, expectedOverviews[i], o) 175 | } 176 | } 177 | 178 | if err = conn.Quit(); err != nil { 179 | t.Fatal("Quit shouldn't error: " + err.Error()) 180 | } 181 | 182 | actualcmds := cmdbuf.String() 183 | if basicClient != actualcmds { 184 | t.Fatalf("Got:\n%s\nExpected\n%s", actualcmds, basicClient) 185 | } 186 | } 187 | 188 | var basicServer = `101 Capability list: 189 | VERSION 2 190 | . 191 | 111 20100329034158 192 | 215 Blah blah 193 | foo 7 3 y 194 | bar 000008 02 m 195 | . 196 | 211 100 1 100 gmane.comp.lang.go.general 197 | 223 1 status 198 | 223 2 Article retrieved 199 | 223 1 Article retrieved 200 | 220 1 article 201 | Path: fake!not-for-mail 202 | From: Someone 203 | Newsgroups: gmane.comp.lang.go.general 204 | Subject: [go-nuts] What about base members? 205 | Message-ID: 206 | 207 | Blah, blah. 208 | ..A single leading . 209 | Fin. 210 | . 211 | 220 2 article 212 | Message-ID: 213 | 214 | Body. 215 | . 216 | 221 100 head 217 | Path: fake!not-for-mail 218 | Message-ID: 219 | . 220 | 221 100 head 221 | Path: fake!not-for-mail 222 | Message-ID: 223 | . 224 | 423 Bad article number 225 | 423 Bad article number 226 | 222 1 body 227 | Blah, blah. 228 | ..A single leading . 229 | Fin. 230 | . 231 | 230 list of new articles by message-id follows 232 | 233 | . 234 | 231 New newsgroups follow 235 | . 236 | 224 Overview information for 10-11 follows 237 | 10 Subject10 Author Sat, 18 Oct 2003 18:00:00 +0030 1000 9 238 | 11 Subject11 18 Oct 2003 19:00:00 +0030 2000 18 Extra stuff 239 | . 240 | 205 Bye! 241 | ` 242 | 243 | var basicClient = `CAPABILITIES 244 | DATE 245 | LIST 246 | GROUP gmane.comp.lang.go.general 247 | STAT 248 | NEXT 249 | LAST 250 | ARTICLE 1 251 | ARTICLE 2 252 | HEAD 100 253 | HEAD 254 | HEAD 0 255 | HEAD 101 256 | BODY 1 257 | NEWNEWS gmane.comp.lang.go.general 20100301 000000 GMT 258 | NEWGROUPS 20100301 000000 GMT 259 | OVER 10-11 260 | QUIT 261 | ` 262 | --------------------------------------------------------------------------------