netget/client/nntp/
mod.rs1pub 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}