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() }