570 lines
14 KiB
Go
570 lines
14 KiB
Go
// Package nntpserver provides everything you need for your own NNTP server.
|
|
package nntpserver
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.maride.cc/maride/go-nntp-plusplus"
|
|
)
|
|
|
|
// An NNTPError is a coded NNTP error message.
|
|
type NNTPError struct {
|
|
Code int
|
|
Msg string
|
|
}
|
|
|
|
// ErrNoSuchGroup is returned for a request for a group that can't be found.
|
|
var ErrNoSuchGroup = &NNTPError{411, "No such newsgroup"}
|
|
|
|
// ErrNoSuchGroup is returned for a request that requires a current
|
|
// group when none has been selected.
|
|
var ErrNoGroupSelected = &NNTPError{412, "No newsgroup selected"}
|
|
|
|
// ErrInvalidMessageID is returned when a message is requested that can't be found.
|
|
var ErrInvalidMessageID = &NNTPError{430, "No article with that message-id"}
|
|
|
|
// ErrInvalidArticleNumber is returned when an article is requested that can't be found.
|
|
var ErrInvalidArticleNumber = &NNTPError{423, "No article with that number"}
|
|
|
|
// ErrNoCurrentArticle is returned when a command is executed that
|
|
// requires a current article when one has not been selected.
|
|
var ErrNoCurrentArticle = &NNTPError{420, "Current article number is invalid"}
|
|
|
|
// ErrUnknownCommand is returned for unknown comands.
|
|
var ErrUnknownCommand = &NNTPError{500, "Unknown command"}
|
|
|
|
// ErrSyntax is returned when a command can't be parsed.
|
|
var ErrSyntax = &NNTPError{501, "not supported, or syntax error"}
|
|
|
|
// ErrPostingNotPermitted is returned as the response to an attempt to
|
|
// post an article where posting is not permitted.
|
|
var ErrPostingNotPermitted = &NNTPError{440, "Posting not permitted"}
|
|
|
|
// ErrPostingFailed is returned when an attempt to post an article fails.
|
|
var ErrPostingFailed = &NNTPError{441, "posting failed"}
|
|
|
|
// ErrNotWanted is returned when an attempt to post an article is
|
|
// rejected due the server not wanting the article.
|
|
var ErrNotWanted = &NNTPError{435, "Article not wanted"}
|
|
|
|
// ErrAuthRequired is returned to indicate authentication is required
|
|
// to proceed.
|
|
var ErrAuthRequired = &NNTPError{450, "authorization required"}
|
|
|
|
// ErrAuthRejected is returned for invalid authentication.
|
|
var ErrAuthRejected = &NNTPError{452, "authorization rejected"}
|
|
|
|
// ErrNotAuthenticated is returned when a command is issued that requires
|
|
// authentication, but authentication was not provided.
|
|
var ErrNotAuthenticated = &NNTPError{480, "authentication required"}
|
|
|
|
// Handler is a low-level protocol handler
|
|
type Handler func(args []string, s *session, c *textproto.Conn) error
|
|
|
|
// A NumberedArticle provides local sequence nubers to articles When
|
|
// listing articles in a group.
|
|
type NumberedArticle struct {
|
|
Num int64
|
|
Article *nntp.Article
|
|
}
|
|
|
|
// The Backend that provides the things and does the stuff.
|
|
type Backend interface {
|
|
ListGroups(max int) ([]*nntp.Group, error)
|
|
GetGroup(name string) (*nntp.Group, error)
|
|
GetArticle(group *nntp.Group, id string) (*nntp.Article, error)
|
|
GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error)
|
|
Authorized() bool
|
|
// Authenticate and optionally swap out the backend for this session.
|
|
// You may return nil to continue using the same backend.
|
|
Authenticate(user, pass string) (Backend, error)
|
|
AllowPost() bool
|
|
Post(article *nntp.Article) error
|
|
}
|
|
|
|
type session struct {
|
|
server *Server
|
|
backend Backend
|
|
group *nntp.Group
|
|
}
|
|
|
|
// The Server handle.
|
|
type Server struct {
|
|
// Handlers are dispatched by command name.
|
|
Handlers map[string]Handler
|
|
// The backend (your code) that provides data
|
|
Backend Backend
|
|
// The currently selected group.
|
|
group *nntp.Group
|
|
}
|
|
|
|
// NewServer builds a new server handle request to a backend.
|
|
func NewServer(backend Backend) *Server {
|
|
rv := Server{
|
|
Handlers: make(map[string]Handler),
|
|
Backend: backend,
|
|
}
|
|
rv.Handlers[""] = handleDefault
|
|
rv.Handlers["quit"] = handleQuit
|
|
rv.Handlers["group"] = handleGroup
|
|
rv.Handlers["list"] = handleList
|
|
rv.Handlers["head"] = handleHead
|
|
rv.Handlers["body"] = handleBody
|
|
rv.Handlers["article"] = handleArticle
|
|
rv.Handlers["post"] = handlePost
|
|
rv.Handlers["ihave"] = handleIHave
|
|
rv.Handlers["capabilities"] = handleCap
|
|
rv.Handlers["mode"] = handleMode
|
|
rv.Handlers["authinfo"] = handleAuthInfo
|
|
rv.Handlers["newgroups"] = handleNewGroups
|
|
rv.Handlers["over"] = handleOver
|
|
rv.Handlers["xover"] = handleOver
|
|
return &rv
|
|
}
|
|
|
|
func (e *NNTPError) Error() string {
|
|
return fmt.Sprintf("%d %s", e.Code, e.Msg)
|
|
}
|
|
|
|
func (s *session) dispatchCommand(cmd string, args []string,
|
|
c *textproto.Conn) (err error) {
|
|
|
|
handler, found := s.server.Handlers[strings.ToLower(cmd)]
|
|
if !found {
|
|
handler, found = s.server.Handlers[""]
|
|
if !found {
|
|
panic("No default handler.")
|
|
}
|
|
}
|
|
return handler(args, s, c)
|
|
}
|
|
|
|
// Process an NNTP session.
|
|
func (s *Server) Process(nc net.Conn) {
|
|
defer nc.Close()
|
|
c := textproto.NewConn(nc)
|
|
|
|
sess := &session{
|
|
server: s,
|
|
backend: s.Backend,
|
|
group: nil,
|
|
}
|
|
|
|
c.PrintfLine("200 Hello!")
|
|
for {
|
|
l, err := c.ReadLine()
|
|
if err != nil {
|
|
log.Printf("Error reading from client, dropping conn: %v", err)
|
|
return
|
|
}
|
|
cmd := strings.Split(l, " ")
|
|
log.Printf("Got cmd: %+v", cmd)
|
|
args := []string{}
|
|
if len(cmd) > 1 {
|
|
args = cmd[1:]
|
|
}
|
|
err = sess.dispatchCommand(cmd[0], args, c)
|
|
if err != nil {
|
|
_, isNNTPError := err.(*NNTPError)
|
|
switch {
|
|
case err == io.EOF:
|
|
// Drop this connection silently. They hung up
|
|
return
|
|
case isNNTPError:
|
|
c.PrintfLine(err.Error())
|
|
default:
|
|
log.Printf("Error dispatching command, dropping conn: %v",
|
|
err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseRange(spec string) (low, high int64) {
|
|
if spec == "" {
|
|
return 0, math.MaxInt64
|
|
}
|
|
parts := strings.Split(spec, "-")
|
|
if len(parts) == 1 {
|
|
h, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
h = math.MaxInt64
|
|
}
|
|
return 0, h
|
|
}
|
|
l, _ := strconv.ParseInt(parts[0], 10, 64)
|
|
h, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
h = math.MaxInt64
|
|
}
|
|
return l, h
|
|
}
|
|
|
|
/*
|
|
"0" or article number (see below)
|
|
Subject header content
|
|
From header content
|
|
Date header content
|
|
Message-ID header content
|
|
References header content
|
|
:bytes metadata item
|
|
:lines metadata item
|
|
*/
|
|
|
|
func handleOver(args []string, s *session, c *textproto.Conn) error {
|
|
if s.group == nil {
|
|
return ErrNoGroupSelected
|
|
}
|
|
from, to := parseRange(args[0])
|
|
articles, err := s.backend.GetArticles(s.group, from, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("224 here it comes")
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
for _, a := range articles {
|
|
fmt.Fprintf(dw, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", a.Num,
|
|
a.Article.Header.Get("Subject"),
|
|
a.Article.Header.Get("From"),
|
|
a.Article.Header.Get("Date"),
|
|
a.Article.Header.Get("Message-Id"),
|
|
a.Article.Header.Get("References"),
|
|
a.Article.Bytes, a.Article.Lines)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleListOverviewFmt(c *textproto.Conn) error {
|
|
err := c.PrintfLine("215 Order of fields in overview database.")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
_, err = fmt.Fprintln(dw, `Subject:
|
|
From:
|
|
Date:
|
|
Message-ID:
|
|
References:
|
|
:bytes
|
|
:lines`)
|
|
return err
|
|
}
|
|
|
|
func handleList(args []string, s *session, c *textproto.Conn) error {
|
|
ltype := "active"
|
|
if len(args) > 0 {
|
|
ltype = strings.ToLower(args[0])
|
|
}
|
|
|
|
if ltype == "overview.fmt" {
|
|
return handleListOverviewFmt(c)
|
|
}
|
|
|
|
groups, err := s.backend.ListGroups(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("215 list of newsgroups follows")
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
for _, g := range groups {
|
|
switch ltype {
|
|
case "active":
|
|
fmt.Fprintf(dw, "%s %d %d %v\r\n",
|
|
g.Name, g.High, g.Low, g.Posting)
|
|
case "newsgroups":
|
|
fmt.Fprintf(dw, "%s %s\r\n", g.Name, g.Description)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func handleNewGroups(args []string, s *session, c *textproto.Conn) error {
|
|
c.PrintfLine("231 list of newsgroups follows")
|
|
c.PrintfLine(".")
|
|
return nil
|
|
}
|
|
|
|
func handleDefault(args []string, s *session, c *textproto.Conn) error {
|
|
return ErrUnknownCommand
|
|
}
|
|
|
|
func handleQuit(args []string, s *session, c *textproto.Conn) error {
|
|
c.PrintfLine("205 bye")
|
|
return io.EOF
|
|
}
|
|
|
|
func handleGroup(args []string, s *session, c *textproto.Conn) error {
|
|
if len(args) < 1 {
|
|
return ErrNoSuchGroup
|
|
}
|
|
|
|
group, err := s.backend.GetGroup(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.group = group
|
|
|
|
c.PrintfLine("211 %d %d %d %s",
|
|
group.Count, group.Low, group.High, group.Name)
|
|
return nil
|
|
}
|
|
|
|
func (s *session) getArticle(args []string) (*nntp.Article, error) {
|
|
if s.group == nil {
|
|
return nil, ErrNoGroupSelected
|
|
}
|
|
return s.backend.GetArticle(s.group, args[0])
|
|
}
|
|
|
|
/*
|
|
Syntax
|
|
HEAD message-id
|
|
HEAD number
|
|
HEAD
|
|
|
|
|
|
First form (message-id specified)
|
|
221 0|n message-id Headers follow (multi-line)
|
|
430 No article with that message-id
|
|
|
|
Second form (article number specified)
|
|
221 n message-id Headers follow (multi-line)
|
|
412 No newsgroup selected
|
|
423 No article with that number
|
|
|
|
Third form (current article number used)
|
|
221 n message-id Headers follow (multi-line)
|
|
412 No newsgroup selected
|
|
420 Current article number is invalid
|
|
*/
|
|
|
|
func handleHead(args []string, s *session, c *textproto.Conn) error {
|
|
article, err := s.getArticle(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("221 1 %s", article.MessageID())
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
for k, v := range article.Header {
|
|
fmt.Fprintf(dw, "%s: %s\r\n", k, v[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
Syntax
|
|
BODY message-id
|
|
BODY number
|
|
BODY
|
|
|
|
Responses
|
|
|
|
First form (message-id specified)
|
|
222 0|n message-id Body follows (multi-line)
|
|
430 No article with that message-id
|
|
|
|
Second form (article number specified)
|
|
222 n message-id Body follows (multi-line)
|
|
412 No newsgroup selected
|
|
423 No article with that number
|
|
|
|
Third form (current article number used)
|
|
222 n message-id Body follows (multi-line)
|
|
412 No newsgroup selected
|
|
420 Current article number is invalid
|
|
|
|
Parameters
|
|
number Requested article number
|
|
n Returned article number
|
|
message-id Article message-id
|
|
*/
|
|
|
|
func handleBody(args []string, s *session, c *textproto.Conn) error {
|
|
article, err := s.getArticle(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("222 1 %s", article.MessageID())
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
_, err = io.Copy(dw, article.Body)
|
|
return err
|
|
}
|
|
|
|
/*
|
|
Syntax
|
|
ARTICLE message-id
|
|
ARTICLE number
|
|
ARTICLE
|
|
|
|
Responses
|
|
|
|
First form (message-id specified)
|
|
220 0|n message-id Article follows (multi-line)
|
|
430 No article with that message-id
|
|
|
|
Second form (article number specified)
|
|
220 n message-id Article follows (multi-line)
|
|
412 No newsgroup selected
|
|
423 No article with that number
|
|
|
|
Third form (current article number used)
|
|
220 n message-id Article follows (multi-line)
|
|
412 No newsgroup selected
|
|
420 Current article number is invalid
|
|
|
|
Parameters
|
|
number Requested article number
|
|
n Returned article number
|
|
message-id Article message-id
|
|
*/
|
|
|
|
func handleArticle(args []string, s *session, c *textproto.Conn) error {
|
|
article, err := s.getArticle(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("220 1 %s", article.MessageID())
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
|
|
for k, v := range article.Header {
|
|
fmt.Fprintf(dw, "%s: %s\r\n", k, v[0])
|
|
}
|
|
|
|
fmt.Fprintln(dw, "")
|
|
|
|
_, err = io.Copy(dw, article.Body)
|
|
return err
|
|
}
|
|
|
|
/*
|
|
Syntax
|
|
POST
|
|
|
|
Responses
|
|
|
|
Initial responses
|
|
340 Send article to be posted
|
|
440 Posting not permitted
|
|
|
|
Subsequent responses
|
|
240 Article received OK
|
|
441 Posting failed
|
|
*/
|
|
|
|
func handlePost(args []string, s *session, c *textproto.Conn) error {
|
|
if !s.backend.AllowPost() {
|
|
return ErrPostingNotPermitted
|
|
}
|
|
|
|
c.PrintfLine("340 Go ahead")
|
|
var err error
|
|
var article nntp.Article
|
|
article.Header, err = c.ReadMIMEHeader()
|
|
if err != nil {
|
|
return ErrPostingFailed
|
|
}
|
|
article.Body = c.DotReader()
|
|
err = s.backend.Post(&article)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("240 article received OK")
|
|
return nil
|
|
}
|
|
|
|
func handleIHave(args []string, s *session, c *textproto.Conn) error {
|
|
if !s.backend.AllowPost() {
|
|
return ErrNotWanted
|
|
}
|
|
|
|
// XXX: See if we have it.
|
|
article, err := s.backend.GetArticle(nil, args[0])
|
|
if article != nil {
|
|
return ErrNotWanted
|
|
}
|
|
|
|
c.PrintfLine("335 send it")
|
|
article = &nntp.Article{}
|
|
article.Header, err = c.ReadMIMEHeader()
|
|
if err != nil {
|
|
return ErrPostingFailed
|
|
}
|
|
article.Body = c.DotReader()
|
|
err = s.backend.Post(article)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.PrintfLine("235 article received OK")
|
|
return nil
|
|
}
|
|
|
|
func handleCap(args []string, s *session, c *textproto.Conn) error {
|
|
c.PrintfLine("101 Capability list:")
|
|
dw := c.DotWriter()
|
|
defer dw.Close()
|
|
|
|
fmt.Fprintf(dw, "VERSION 2\n")
|
|
fmt.Fprintf(dw, "READER\n")
|
|
if s.backend.AllowPost() {
|
|
fmt.Fprintf(dw, "POST\n")
|
|
fmt.Fprintf(dw, "IHAVE\n")
|
|
}
|
|
fmt.Fprintf(dw, "OVER\n")
|
|
fmt.Fprintf(dw, "XOVER\n")
|
|
fmt.Fprintf(dw, "LIST ACTIVE NEWSGROUPS OVERVIEW.FMT\n")
|
|
return nil
|
|
}
|
|
|
|
func handleMode(args []string, s *session, c *textproto.Conn) error {
|
|
if s.backend.AllowPost() {
|
|
c.PrintfLine("200 Posting allowed")
|
|
} else {
|
|
c.PrintfLine("201 Posting prohibited")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleAuthInfo(args []string, s *session, c *textproto.Conn) error {
|
|
if len(args) < 2 {
|
|
return ErrSyntax
|
|
}
|
|
if strings.ToLower(args[0]) != "user" {
|
|
return ErrSyntax
|
|
}
|
|
|
|
if s.backend.Authorized() {
|
|
return c.PrintfLine("281 authenticated")
|
|
}
|
|
|
|
c.PrintfLine("350 Continue")
|
|
a, err := c.ReadLine()
|
|
parts := strings.SplitN(a, " ", 3)
|
|
if strings.ToLower(parts[0]) != "authinfo" || strings.ToLower(parts[1]) != "pass" {
|
|
return ErrSyntax
|
|
}
|
|
b, err := s.backend.Authenticate(args[1], parts[2])
|
|
if err == nil {
|
|
c.PrintfLine("281 authenticated")
|
|
if b != nil {
|
|
s.backend = b
|
|
}
|
|
}
|
|
return err
|
|
}
|