Skip to main content

netget/client/nntp/
mod.rs

1//! Sequential, bounded NNTP transactions over TCP or verified implicit TLS.
2pub mod actions;
3use crate::server::p2p_support::{Framer, ScannerSession, Stream};
4pub use actions::NntpClientProtocol;
5use anyhow::{ensure, Context, Result};
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use tokio::io::AsyncWriteExt;
9#[derive(Default)]
10pub struct Session {
11    frame: Framer,
12    welcome: String,
13    tls: bool,
14}
15impl Session {
16    async fn response(&mut self, s: &mut Stream, command: &str) -> Result<Value> {
17        let raw = self.frame.delimited(s, b'\n', 64 * 1024).await?;
18        ensure!(raw.ends_with(b"\r\n"), "NNTP response must end in CRLF");
19        let line = String::from_utf8(raw)?;
20        ensure!(
21            line.len() >= 5 && line.as_bytes()[3] == b' ',
22            "invalid NNTP status line"
23        );
24        let code: u16 = line[..3].parse()?;
25        ensure!((100..600).contains(&code), "invalid status code");
26        let mut response = line.trim_end_matches("\r\n").to_string();
27        if matches!(
28            code,
29            100 | 101 | 215 | 220 | 221 | 222 | 224 | 225 | 230 | 231
30        ) {
31            loop {
32                let raw = self.frame.delimited(s, b'\n', 64 * 1024).await?;
33                ensure!(raw.ends_with(b"\r\n"), "multiline NNTP framing");
34                let text = std::str::from_utf8(&raw)?.trim_end_matches("\r\n");
35                if text == "." {
36                    break;
37                }
38                let text = if text.starts_with("..") {
39                    &text[1..]
40                } else {
41                    text
42                };
43                ensure!(
44                    response.len() + text.len() + 1 <= 8 * 1024 * 1024,
45                    "NNTP multiline reply too large"
46                );
47                response.push('\n');
48                response.push_str(text);
49            }
50        }
51        let safe_command = if command.to_ascii_uppercase().starts_with("AUTHINFO PASS ") {
52            "AUTHINFO PASS [redacted]"
53        } else {
54            command
55        };
56        Ok(json!({"command":safe_command,"code":code,"status_code":code,"response":response}))
57    }
58    async fn command(&mut self, s: &mut Stream, command: &str) -> Result<Value> {
59        ensure!(
60            command.len() <= 510 && !command.contains(['\r', '\n', '\0']),
61            "invalid NNTP command"
62        );
63        s.write_all(format!("{command}\r\n").as_bytes()).await?;
64        self.response(s, command).await
65    }
66}
67#[async_trait]
68impl ScannerSession for Session {
69    async fn open(&mut self, s: &mut Stream) -> Result<()> {
70        let v = self.response(s, "GREETING").await?;
71        ensure!(
72            v["code"] == 200 || v["code"] == 201,
73            "NNTP greeting refused"
74        );
75        self.welcome = v["response"].as_str().context("welcome")?.to_string();
76        Ok(())
77    }
78    fn outcome(
79        &self,
80        a: &Value,
81        response: &Value,
82    ) -> crate::state::client_handles::ClientSendOutcome {
83        use crate::llm::actions::client_trait::{Client, ClientActionResult};
84        if let Ok(ClientActionResult::Custom { name, data }) =
85            NntpClientProtocol::new().execute_action(a.clone())
86        {
87            if name == "nntp_command" {
88                return crate::state::client_handles::ClientSendOutcome::Sent {
89                    bytes_sent: data["command"].as_str().map_or(0, |s| s.len() + 2),
90                };
91            }
92        }
93        crate::state::client_handles::ClientSendOutcome::Executed {
94            detail: response.to_string(),
95        }
96    }
97    async fn close(&mut self, s: &mut Stream) -> Result<()> {
98        let result = self.command(s, "QUIT").await?;
99        ensure!(result["code"] == 205, "NNTP QUIT refused");
100        Ok(())
101    }
102    fn connected(&self) -> Value {
103        json!({"welcome_message":self.welcome})
104    }
105    async fn exchange(&mut self, s: &mut Stream, a: &Value) -> Result<Value> {
106        use crate::llm::actions::client_trait::{Client, ClientActionResult};
107        match NntpClientProtocol::new().execute_action(a.clone())? {
108            ClientActionResult::Custom { name, data } if name == "nntp_command" => {
109                self.command(s, data["command"].as_str().context("command")?)
110                    .await
111            }
112            ClientActionResult::Custom { name, data } if name == "nntp_authenticate" => {
113                ensure!(self.tls, "NNTP credentials require implicit TLS");
114                let user = crate::server::nntp::extensions::field(
115                    data["username"].as_str().context("username")?,
116                )?;
117                let password = crate::server::nntp::extensions::field(
118                    data["password"].as_str().context("password")?,
119                )?;
120                let result = self.command(s, &format!("AUTHINFO USER {user}")).await?;
121                if result["code"] != 381 {
122                    return Ok(result);
123                }
124                self.command(s, &format!("AUTHINFO PASS {password}")).await
125            }
126            ClientActionResult::Custom { name, data }
127                if matches!(name.as_str(), "nntp_post" | "nntp_ihave" | "nntp_takethis") =>
128            {
129                let article = crate::server::nntp::extensions::article(&data)?;
130                let (command, continue_code) = if name == "nntp_post" {
131                    ("POST".to_string(), 340)
132                } else {
133                    let id = crate::server::nntp::extensions::message_id(
134                        data["message_id"].as_str().context("message_id")?,
135                    )?;
136                    (
137                        format!(
138                            "{} {id}",
139                            if name == "nntp_ihave" {
140                                "IHAVE"
141                            } else {
142                                "TAKETHIS"
143                            }
144                        ),
145                        335,
146                    )
147                };
148                if name == "nntp_takethis" {
149                    s.write_all(format!("{command}\r\n").as_bytes()).await?;
150                } else {
151                    let response = self.command(s, &command).await?;
152                    if response["code"] != continue_code {
153                        return Ok(response);
154                    }
155                }
156                s.write_all(&article).await?;
157                self.response(s, &command).await
158            }
159            _ => anyhow::bail!("unsupported NNTP transaction"),
160        }
161    }
162}
163pub async fn connect(ctx: crate::protocol::ConnectContext) -> Result<std::net::SocketAddr> {
164    let tls = crate::server::p2p_support::use_tls(ctx.startup_params.as_ref())?;
165    crate::server::p2p_support::connect(
166        ctx,
167        std::sync::Arc::new(NntpClientProtocol::new()),
168        Session {
169            tls,
170            ..Default::default()
171        },
172        [
173            &actions::NNTP_CLIENT_CONNECTED_EVENT,
174            &actions::NNTP_CLIENT_RESPONSE_RECEIVED_EVENT,
175        ],
176    )
177    .await
178}