363 lines
8.6 KiB
Go
363 lines
8.6 KiB
Go
// Package nntpclient provides an NNTP Client.
|
|
package nntpclient
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.maride.cc/maride/go-nntp-plusplus"
|
|
)
|
|
|
|
// Client is an NNTP client.
|
|
type Client struct {
|
|
conn *textproto.Conn
|
|
netconn net.Conn
|
|
tls bool
|
|
Banner string
|
|
capabilities []string
|
|
}
|
|
|
|
// New connects a client to an NNTP server.
|
|
func New(network, addr string) (*Client, error) {
|
|
netconn, err := net.Dial(network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return connect(netconn)
|
|
}
|
|
|
|
// NewConn wraps an existing connection, for example one opened with tls.Dial
|
|
func NewConn(netconn net.Conn) (*Client, error) {
|
|
client, err := connect(netconn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := netconn.(*tls.Conn); ok {
|
|
client.tls = true
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// NewTLS connects to an NNTP server over a dedicated TLS port like 563
|
|
func NewTLS(network, addr string, config *tls.Config) (*Client, error) {
|
|
netconn, err := tls.Dial(network, addr, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := connect(netconn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.tls = true
|
|
return client, nil
|
|
}
|
|
|
|
func connect(netconn net.Conn) (*Client, error) {
|
|
conn := textproto.NewConn(netconn)
|
|
_, msg, err := conn.ReadCodeLine(200)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Client{
|
|
conn: conn,
|
|
netconn: netconn,
|
|
Banner: msg,
|
|
}, nil
|
|
}
|
|
|
|
// Close this client.
|
|
func (c *Client) Close() error {
|
|
return c.conn.Close()
|
|
}
|
|
|
|
// Authenticate against an NNTP server using authinfo user/pass
|
|
func (c *Client) Authenticate(user, pass string) (msg string, err error) {
|
|
err = c.conn.PrintfLine("authinfo user %s", user)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _, err = c.conn.ReadCodeLine(381)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = c.conn.PrintfLine("authinfo pass %s", pass)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, msg, err = c.conn.ReadCodeLine(250)
|
|
return
|
|
}
|
|
|
|
func parsePosting(p string) nntp.PostingStatus {
|
|
switch p {
|
|
case "y":
|
|
return nntp.PostingPermitted
|
|
case "m":
|
|
return nntp.PostingModerated
|
|
}
|
|
return nntp.PostingNotPermitted
|
|
}
|
|
|
|
// List groups
|
|
func (c *Client) List(sub string) (rv []nntp.Group, err error) {
|
|
_, _, err = c.Command("LIST "+sub, 215)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var groupLines []string
|
|
groupLines, err = c.conn.ReadDotLines()
|
|
if err != nil {
|
|
return
|
|
}
|
|
rv = make([]nntp.Group, 0, len(groupLines))
|
|
for _, l := range groupLines {
|
|
parts := strings.Split(l, " ")
|
|
high, errh := strconv.ParseInt(parts[1], 10, 64)
|
|
low, errl := strconv.ParseInt(parts[2], 10, 64)
|
|
if errh == nil && errl == nil {
|
|
rv = append(rv, nntp.Group{
|
|
Name: parts[0],
|
|
High: high,
|
|
Low: low,
|
|
Posting: parsePosting(parts[3]),
|
|
})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Group selects a group.
|
|
func (c *Client) Group(name string) (rv nntp.Group, err error) {
|
|
var msg string
|
|
_, msg, err = c.Command("GROUP "+name, 211)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// count first last name
|
|
parts := strings.Split(msg, " ")
|
|
if len(parts) != 4 {
|
|
err = errors.New("Don't know how to parse result: " + msg)
|
|
}
|
|
rv.Count, err = strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rv.Low, err = strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rv.High, err = strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
rv.Name = parts[3]
|
|
|
|
return
|
|
}
|
|
|
|
// Article grabs an article
|
|
func (c *Client) Article(specifier string) (int64, string, io.Reader, error) {
|
|
err := c.conn.PrintfLine("ARTICLE %s", specifier)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
return c.articleish(220)
|
|
}
|
|
|
|
// Head gets the headers for an article
|
|
func (c *Client) Head(specifier string) (int64, string, io.Reader, error) {
|
|
err := c.conn.PrintfLine("HEAD %s", specifier)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
return c.articleish(221)
|
|
}
|
|
|
|
// Body gets the body of an article
|
|
func (c *Client) Body(specifier string) (int64, string, io.Reader, error) {
|
|
err := c.conn.PrintfLine("BODY %s", specifier)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
return c.articleish(222)
|
|
}
|
|
|
|
func (c *Client) articleish(expected int) (int64, string, io.Reader, error) {
|
|
_, msg, err := c.conn.ReadCodeLine(expected)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
parts := strings.SplitN(msg, " ", 2)
|
|
n, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return 0, "", nil, err
|
|
}
|
|
return n, parts[1], c.conn.DotReader(), nil
|
|
}
|
|
|
|
// Post a new article
|
|
//
|
|
// The reader should contain the entire article, headers and body in
|
|
// RFC822ish format.
|
|
func (c *Client) Post(r io.Reader) error {
|
|
err := c.conn.PrintfLine("POST")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _, err = c.conn.ReadCodeLine(340)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w := c.conn.DotWriter()
|
|
_, err = io.Copy(w, r)
|
|
if err != nil {
|
|
// This seems really bad
|
|
return err
|
|
}
|
|
w.Close()
|
|
_, _, err = c.conn.ReadCodeLine(240)
|
|
return err
|
|
}
|
|
|
|
// Command sends a low-level command and get a response.
|
|
//
|
|
// This will return an error if the code doesn't match the expectCode
|
|
// prefix. For example, if you specify "200", the response code MUST
|
|
// be 200 or you'll get an error. If you specify "2", any code from
|
|
// 200 (inclusive) to 300 (exclusive) will be success. An expectCode
|
|
// of -1 disables this behavior.
|
|
func (c *Client) Command(cmd string, expectCode int) (int, string, error) {
|
|
err := c.conn.PrintfLine(cmd)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
return c.conn.ReadCodeLine(expectCode)
|
|
}
|
|
|
|
// asLines issues a command and returns the response's data block as lines.
|
|
func (c *Client) asLines(cmd string, expectCode int) ([]string, error) {
|
|
_, _, err := c.Command(cmd, expectCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.conn.ReadDotLines()
|
|
}
|
|
|
|
// Capabilities retrieves a list of supported capabilities.
|
|
//
|
|
// See https://datatracker.ietf.org/doc/html/rfc3977#section-5.2.2
|
|
func (c *Client) Capabilities() ([]string, error) {
|
|
caps, err := c.asLines("CAPABILITIES", 101)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i, line := range caps {
|
|
caps[i] = strings.ToUpper(line)
|
|
}
|
|
c.capabilities = caps
|
|
return caps, nil
|
|
}
|
|
|
|
// GetCapability returns a complete capability line.
|
|
//
|
|
// "Each capability line consists of one or more tokens, which MUST be
|
|
// separated by one or more space or TAB characters."
|
|
//
|
|
// From https://datatracker.ietf.org/doc/html/rfc3977#section-3.3.1
|
|
func (c *Client) GetCapability(capability string) string {
|
|
capability = strings.ToUpper(capability)
|
|
for _, capa := range c.capabilities {
|
|
i := strings.IndexAny(capa, "\t ")
|
|
if i != -1 && capa[:i] == capability {
|
|
return capa
|
|
}
|
|
if capa == capability {
|
|
return capa
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HasCapabilityArgument indicates whether a capability arg is supported.
|
|
//
|
|
// Here, "argument" means any token after the label in a capabilities response
|
|
// line. Some, like "ACTIVE" in "LIST ACTIVE", are not command arguments but
|
|
// rather "keyword" components of compound commands called "variants."
|
|
//
|
|
// See https://datatracker.ietf.org/doc/html/rfc3977#section-9.5
|
|
func (c *Client) HasCapabilityArgument(
|
|
capability, argument string,
|
|
) (bool, error) {
|
|
if c.capabilities == nil {
|
|
return false, errors.New("Capabilities unpopulated")
|
|
}
|
|
capLine := c.GetCapability(capability)
|
|
if capLine == "" {
|
|
return false, errors.New("No such capability")
|
|
}
|
|
argument = strings.ToUpper(argument)
|
|
for _, capArg := range strings.Fields(capLine)[1:] {
|
|
if capArg == argument {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// ListOverviewFmt performs a LIST OVERVIEW.FMT query.
|
|
//
|
|
// According to the spec, the presence of an "OVER" line in the capabilities
|
|
// response means this LIST variant is supported, so there's no reason to
|
|
// check for it among the keywords in the "LIST" line, strictly speaking.
|
|
//
|
|
// See https://datatracker.ietf.org/doc/html/rfc3977#section-3.3.2
|
|
func (c *Client) ListOverviewFmt() ([]string, error) {
|
|
fields, err := c.asLines("LIST OVERVIEW.FMT", 215)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// Over returns a list of raw overview lines with tab-separated fields.
|
|
func (c *Client) Over(specifier string) ([]string, error) {
|
|
lines, err := c.asLines("OVER "+specifier, 224)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return lines, nil
|
|
}
|
|
|
|
func (c *Client) HasTLS() bool {
|
|
return c.tls
|
|
}
|
|
|
|
// StartTLS sends the STARTTLS command and refreshes capabilities.
|
|
//
|
|
// See https://datatracker.ietf.org/doc/html/rfc4642 and net/smtp.go, from
|
|
// which this was adapted, and maybe NNTP.startls in Python's nntplib also.
|
|
func (c *Client) StartTLS(config *tls.Config) error {
|
|
if c.tls {
|
|
return errors.New("TLS already active")
|
|
}
|
|
_, _, err := c.Command("STARTTLS", 382)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.netconn = tls.Client(c.netconn, config)
|
|
c.conn = textproto.NewConn(c.netconn)
|
|
c.tls = true
|
|
_, err = c.Capabilities()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|