Files
rsser/outputs/nntp/nntp.go
T

220 lines
4.6 KiB
Go
Raw Normal View History

2026-08-05 12:06:46 +05:00
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()
}