220 lines
4.6 KiB
Go
220 lines
4.6 KiB
Go
package nntp
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.dev.pztrn.name/rsser/configuration"
|
|
"go.dev.pztrn.name/rsser/outputs"
|
|
)
|
|
|
|
type nntp struct {
|
|
cfg *configuration.Config
|
|
conn net.Conn
|
|
br *bufio.Reader
|
|
bw *bufio.Writer
|
|
}
|
|
|
|
// New creates new simple NNTP client. Connection will be established only for sending posts.
|
|
func New(cfg *configuration.Config) (outputs.Output, error) {
|
|
c := &nntp{
|
|
cfg: cfg,
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *nntp) establishConnection() error {
|
|
if c.conn != nil {
|
|
return nil
|
|
}
|
|
|
|
//nolint:noctx
|
|
conn, err := net.DialTimeout(
|
|
"tcp",
|
|
c.cfg.Outputs.NNTP.Address,
|
|
time.Second*time.Duration(c.cfg.Outputs.NNTP.ConnectTimeout),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("establishConnection: %w", err)
|
|
}
|
|
|
|
c.conn = conn
|
|
c.br = bufio.NewReader(c.conn)
|
|
c.bw = bufio.NewWriter(c.conn)
|
|
|
|
// Waiting for hello (200/201).
|
|
line, err := c.readLine()
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
|
|
return fmt.Errorf("no greeting from server: %w", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(line, "200") && !strings.HasPrefix(line, "201") {
|
|
_ = conn.Close()
|
|
|
|
//nolint:err113
|
|
return fmt.Errorf("unexpected greeting: %s", line)
|
|
}
|
|
|
|
if err := c.authInfo(); err != nil {
|
|
//nolint:err113
|
|
return fmt.Errorf("auth failed: %s", line)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *nntp) authInfo() error {
|
|
resp, err := c.sendCommand("AUTHINFO USER " + c.cfg.Outputs.NNTP.Username)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !strings.HasPrefix(resp, "381") { // 381 = send PASS
|
|
// Some servers immediately giving 281 (success) or 480 (fail)
|
|
if strings.HasPrefix(resp, "281") {
|
|
return nil // уже авторизованы
|
|
}
|
|
|
|
//nolint:err113
|
|
return fmt.Errorf("AUTHINFO USER failed: %s", resp)
|
|
}
|
|
|
|
resp, err = c.sendCommand("AUTHINFO PASS " + c.cfg.Outputs.NNTP.Password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !strings.HasPrefix(resp, "281") {
|
|
//nolint:err113
|
|
return fmt.Errorf("AUTHINFO PASS failed: %s", resp)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *nntp) Do(dest, feedName, url, title, body string) error {
|
|
headers := map[string]string{
|
|
"From": "\"" + feedName + "\" <" + c.cfg.Outputs.NNTP.Username + "@mail.invalid",
|
|
"Newsgroups": dest,
|
|
"Subject": c.encodeSubjectB(title),
|
|
"Date": time.Now().UTC().Format(time.RFC1123),
|
|
"Content-Type": "text/plain; charset=UTF-8; format=flowed",
|
|
"Content-Transfer-Encoding": "7bit",
|
|
}
|
|
|
|
body = "URL: " + url + "\n\n" + body
|
|
|
|
if err := c.postArticle(headers, body); err != nil {
|
|
return fmt.Errorf("Do: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *nntp) doubleDot(s string) string {
|
|
var result strings.Builder
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(s))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if len(line) > 0 && line[0] == '.' {
|
|
result.WriteString("..")
|
|
result.WriteString(line[1:])
|
|
} else {
|
|
result.WriteString(line)
|
|
}
|
|
|
|
result.WriteByte('\n')
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
func (c *nntp) encodeSubjectB(s string) string {
|
|
b := base64.StdEncoding.EncodeToString([]byte(s))
|
|
|
|
return fmt.Sprintf("=?utf-8?B?%s?=", b)
|
|
}
|
|
|
|
func (c *nntp) Name() string {
|
|
return "nntp"
|
|
}
|
|
|
|
func (c *nntp) postArticle(headers map[string]string, body string) error {
|
|
if err := c.establishConnection(); err != nil {
|
|
return fmt.Errorf("PostArticle: %w", err)
|
|
}
|
|
|
|
resp, err := c.sendCommand("POST")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !strings.HasPrefix(resp, "340") { // 340 = ready to accept article
|
|
//nolint:err113
|
|
return fmt.Errorf("POST not accepted by server: %s", resp)
|
|
}
|
|
|
|
// Заголовки
|
|
for k, v := range headers {
|
|
_, _ = fmt.Fprintf(c.bw, "%s: %s\r\n", k, v)
|
|
}
|
|
|
|
_, _ = fmt.Fprint(c.bw, "\r\n")
|
|
|
|
// Article body: double starting dot (RFC).
|
|
processedBody := c.doubleDot(body)
|
|
_, _ = fmt.Fprint(c.bw, processedBody)
|
|
_, _ = fmt.Fprintln(c.bw, ".")
|
|
|
|
if flushErr := c.bw.Flush(); flushErr != nil {
|
|
return fmt.Errorf("PostArticle: %w", flushErr)
|
|
}
|
|
|
|
finalResp, err := c.readLine()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !strings.HasPrefix(finalResp, "240") {
|
|
//nolint:err113
|
|
return fmt.Errorf("article not accepted: %s", finalResp)
|
|
}
|
|
|
|
slog.Info("Article posted successfully", "resp", finalResp)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *nntp) readLine() (string, error) {
|
|
b, err := c.br.ReadBytes('\n')
|
|
if err != nil {
|
|
return "", fmt.Errorf("readLine: %w", err)
|
|
}
|
|
|
|
s := strings.TrimRight(string(b), "\r\n")
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (c *nntp) sendCommand(cmd string) (string, error) {
|
|
_, err := fmt.Fprintf(c.bw, "%s\r\n", cmd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("readLine: %w", err)
|
|
}
|
|
|
|
if err := c.bw.Flush(); err != nil {
|
|
return "", fmt.Errorf("readLine: %w", err)
|
|
}
|
|
|
|
return c.readLine()
|
|
}
|