1use crate::llm::actions::{client_trait::ClientActionResult, ActionDefinition};
3use crate::server::p2p_support::{action, parameter};
4use anyhow::{ensure, Context, Result};
5use serde_json::{json, Value};
6use std::sync::Arc;
7use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
8pub const MAX_ARTICLE: usize = 8 * 1024 * 1024;
9pub fn field(text: &str) -> Result<&str> {
10 ensure!(
11 !text.is_empty() && text.len() <= 4096 && !text.contains(['\r', '\n', '\0']),
12 "invalid NNTP field"
13 );
14 Ok(text)
15}
16pub fn message_id(text: &str) -> Result<&str> {
17 field(text)?;
18 ensure!(
19 text.starts_with('<')
20 && text.ends_with('>')
21 && !text.bytes().any(|b| b <= 32)
22 && text.contains('@'),
23 "invalid message ID"
24 );
25 Ok(text)
26}
27pub fn dot_block(text: &str) -> Result<Vec<u8>> {
28 ensure!(text.len() <= MAX_ARTICLE, "article too large");
29 let normalized = text.replace("\r\n", "\n");
30 ensure!(
31 !normalized.contains(['\r', '\0']),
32 "invalid article line ending"
33 );
34 let mut out = Vec::new();
35 for line in normalized.split_terminator('\n') {
36 ensure!(line.len() <= 64 * 1024 - 2, "article line too long");
37 if line.starts_with('.') {
38 out.push(b'.');
39 }
40 out.extend_from_slice(line.as_bytes());
41 out.extend_from_slice(b"\r\n");
42 }
43 ensure!(out.len() <= MAX_ARTICLE, "encoded article too large");
44 out.extend_from_slice(b".\r\n");
45 Ok(out)
46}
47pub fn article(v: &Value) -> Result<Vec<u8>> {
48 let headers = v["headers"]
49 .as_object()
50 .context("headers object required")?;
51 let mut text = String::new();
52 ensure!(!headers.is_empty() && headers.len() <= 128, "header count");
53 for (key, val) in headers {
54 ensure!(
55 !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'),
56 "invalid header name"
57 );
58 text.push_str(key);
59 text.push_str(": ");
60 text.push_str(field(val.as_str().context("header value")?)?);
61 text.push('\n');
62 }
63 text.push('\n');
64 text.push_str(v["body"].as_str().context("body required")?);
65 dot_block(&text)
66}
67pub fn client_actions() -> Vec<ActionDefinition> {
68 let mut list = vec![
69 action(
70 "nntp_capabilities",
71 "Discover server capabilities",
72 vec![],
73 json!({"type":"nntp_capabilities"}),
74 ),
75 action(
76 "nntp_mode_stream",
77 "Enter streaming-feed mode",
78 vec![],
79 json!({"type":"nntp_mode_stream"}),
80 ),
81 action(
82 "nntp_check",
83 "Offer an article message ID",
84 vec![parameter(
85 "message_id",
86 "string",
87 "Article message ID in angle brackets, such as <article@example.org>",
88 true,
89 )],
90 json!({"type":"nntp_check","message_id":"<test@localhost>"}),
91 ),
92 action(
93 "nntp_authenticate",
94 "Authenticate over verified TLS",
95 vec![
96 parameter("username", "string", "Account username", true),
97 parameter("password", "string", "Account password", true),
98 ],
99 json!({"type":"nntp_authenticate","username":"user","password":"secret"}),
100 ),
101 ];
102 for name in ["nntp_ihave", "nntp_takethis"] {
103 list.push(action(name,"Feed an article to another news server",vec![parameter("message_id","string","RFC message ID matching the article",true),parameter("headers","object","Article header fields",true),parameter("body","string","Article body text; line normalization and dot stuffing are automatic",true)],json!({"type":name,"message_id":"<test@localhost>","headers":{"Message-ID":"<test@localhost>","From":"test@localhost","Newsgroups":"misc.test","Subject":"Test"},"body":"Hello"})));
104 }
105 list
106}
107pub fn client_action(v: &Value) -> Result<Option<ClientActionResult>> {
108 let name = v["type"].as_str().context("type")?;
109 let command = match name {
110 "nntp_capabilities" => Some("CAPABILITIES".to_string()),
111 "nntp_mode_stream" => Some("MODE STREAM".to_string()),
112 "nntp_check" => Some(format!(
113 "CHECK {}",
114 message_id(v["message_id"].as_str().context("message_id")?)?
115 )),
116 "nntp_authenticate" => {
117 field(v["username"].as_str().context("username")?)?;
118 field(v["password"].as_str().context("password")?)?;
119 return Ok(Some(ClientActionResult::Custom {
120 name: name.into(),
121 data: v.clone(),
122 }));
123 }
124 "nntp_ihave" | "nntp_takethis" => {
125 let id = message_id(v["message_id"].as_str().context("message_id")?)?;
126 article(v)?;
127 ensure!(
128 v["headers"]
129 .as_object()
130 .context("headers")?
131 .iter()
132 .any(|(k, val)| k.eq_ignore_ascii_case("message-id") && val == id),
133 "article message ID mismatch"
134 );
135 return Ok(Some(ClientActionResult::Custom {
136 name: name.into(),
137 data: v.clone(),
138 }));
139 }
140 _ => return Ok(None),
141 };
142 Ok(command.map(|command| ClientActionResult::Custom {
143 name: "nntp_command".into(),
144 data: json!({"command":command}),
145 }))
146}
147pub fn server_actions() -> Vec<ActionDefinition> {
148 [
149 "nntp_auth_result",
150 "nntp_check_result",
151 "nntp_article_result",
152 ]
153 .into_iter()
154 .map(|name| {
155 action(
156 name,
157 "Accept or refuse this authentication, offer or received article",
158 vec![parameter(
159 "accepted",
160 "boolean",
161 "Accept this request; omitted or false refuses",
162 true,
163 )],
164 json!({"type":name,"accepted":false}),
165 )
166 })
167 .collect()
168}
169#[derive(Default)]
170pub struct State {
171 pub authenticated: bool,
172 pub secure: bool,
173 pub username: Option<String>,
174 pub streaming: bool,
175 pub require_auth: bool,
176 pub posting_allowed: bool,
177}
178pub struct ContextRefs<'a> {
179 pub llm: &'a crate::llm::OllamaClient,
180 pub state: &'a Arc<crate::state::AppState>,
181 pub server: crate::state::ServerId,
182 pub peer: crate::server::connection::ConnectionId,
183 pub protocol: &'a super::NntpProtocol,
184}
185async fn decision(ctx: &ContextRefs<'_>, event: Value, kind: &str) -> bool {
186 let event = crate::protocol::Event::new(&super::actions::NNTP_COMMAND_RECEIVED_EVENT, event);
187 let Ok(result) = crate::llm::action_helper::call_llm(
188 ctx.llm,
189 ctx.state,
190 ctx.server,
191 Some(ctx.peer),
192 &event,
193 ctx.protocol,
194 )
195 .await
196 else {
197 return false;
198 };
199 result
200 .protocol_results
201 .iter()
202 .find_map(|v| {
203 if let crate::llm::ActionResult::Custom { name, data } = v {
204 (name == kind).then(|| data["accepted"].as_bool().unwrap_or(false))
205 } else {
206 None
207 }
208 })
209 .unwrap_or(false)
210}
211async fn write<W: AsyncWrite + Unpin>(
212 writer: &Arc<tokio::sync::Mutex<W>>,
213 text: &str,
214) -> Result<()> {
215 tokio::time::timeout(std::time::Duration::from_secs(10), async {
216 writer.lock().await.write_all(text.as_bytes()).await
217 })
218 .await??;
219 Ok(())
220}
221pub async fn handle<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
223 reader: &mut BufReader<R>,
224 writer: &Arc<tokio::sync::Mutex<W>>,
225 line: &str,
226 state: &mut State,
227 ctx: &ContextRefs<'_>,
228) -> Result<bool> {
229 let words: Vec<_> = line.split_whitespace().collect();
230 let verb = words
231 .first()
232 .map(|v| v.to_ascii_uppercase())
233 .unwrap_or_default();
234 if verb == "AUTHINFO" {
235 let mut auth = line.splitn(3, ' ');
236 auth.next();
237 let mode = auth.next().unwrap_or("").to_ascii_uppercase();
238 let value = auth.next().unwrap_or("");
239 match mode.as_str() {
240 "USER" if !value.is_empty() => {
241 if !state.secure {
242 write(writer, "483 TLS required\r\n").await?;
243 } else {
244 state.username = Some(field(value)?.into());
245 write(writer, "381 Password required\r\n").await?;
246 }
247 }
248 "PASS" if !value.is_empty() => {
249 if !state.secure {
250 write(writer, "483 TLS required\r\n").await?;
251 } else if let Some(user) = state.username.take() {
252 let accepted=decision(ctx,json!({"command":"AUTHINFO PASS","operation":"authenticate","username":user,"password":field(value)?,"answer_with":"nntp_auth_result"}),"nntp_auth_result").await;
253 state.authenticated = accepted;
254 write(
255 writer,
256 if accepted {
257 "281 Authentication accepted\r\n"
258 } else {
259 "481 Authentication rejected\r\n"
260 },
261 )
262 .await?;
263 } else {
264 write(writer, "482 Send AUTHINFO USER first\r\n").await?;
265 }
266 }
267 _ => write(writer, "501 Invalid AUTHINFO syntax\r\n").await?,
268 }
269 return Ok(true);
270 }
271 if state.require_auth
272 && !state.authenticated
273 && !matches!(verb.as_str(), "CAPABILITIES" | "QUIT")
274 {
275 write(writer, "480 Authentication required\r\n").await?;
276 return Ok(true);
277 }
278 if verb == "MODE" && words.len() == 2 && words[1].eq_ignore_ascii_case("STREAM") {
279 state.streaming = true;
280 write(writer, "203 Streaming permitted\r\n").await?;
281 return Ok(true);
282 }
283 if verb == "CHECK" {
284 if !state.streaming {
285 write(writer, "500 Enter MODE STREAM first\r\n").await?;
286 return Ok(true);
287 }
288 if words.len() != 2 || message_id(words[1]).is_err() {
289 write(writer, "501 Invalid message ID\r\n").await?;
290 return Ok(true);
291 }
292 let accepted=decision(ctx,json!({"command":line,"operation":"check","message_id":words[1],"answer_with":"nntp_check_result"}),"nntp_check_result").await;
293 write(
294 writer,
295 &format!("{} {}\r\n", if accepted { 238 } else { 438 }, words[1]),
296 )
297 .await?;
298 return Ok(true);
299 }
300 if verb == "MODE"
301 && words
302 .get(1)
303 .is_some_and(|v| v.eq_ignore_ascii_case("READER"))
304 {
305 state.streaming = false;
306 }
307 if !matches!(verb.as_str(), "POST" | "IHAVE" | "TAKETHIS") {
308 return Ok(false);
309 }
310 if verb == "POST" && !state.posting_allowed {
311 write(writer, "440 Posting prohibited\r\n").await?;
312 return Ok(true);
313 }
314 if (verb == "POST" && words.len() != 1)
315 || (verb != "POST" && (words.len() != 2 || message_id(words[1]).is_err()))
316 {
317 write(writer, "501 Invalid article command\r\n").await?;
318 return Ok(true);
319 }
320 let id = if verb == "POST" {
321 None
322 } else {
323 Some(message_id(words[1])?)
324 };
325 if verb == "TAKETHIS" {
326 if !state.streaming {
327 write(writer, "500 Enter MODE STREAM first\r\n").await?;
328 return Ok(true);
329 }
330 } else {
331 write(
332 writer,
333 if verb == "POST" {
334 "340 Send article\r\n"
335 } else {
336 "335 Send article\r\n"
337 },
338 )
339 .await?;
340 }
341 let data = crate::client::response_reader::read_dot_response(reader, String::new()).await?;
342 let article = data.strip_prefix('\n').unwrap_or(&data);
343 let valid_id = id.is_none_or(|id| {
344 article.lines().take_while(|l| !l.is_empty()).any(|l| {
345 l.split_once(':').is_some_and(|(key, value)| {
346 key.eq_ignore_ascii_case("message-id") && value.trim() == id
347 })
348 })
349 });
350 let accepted=valid_id&&decision(ctx,json!({"command":line,"operation":verb.to_ascii_lowercase(),"article":article,"message_id":id,"answer_with":"nntp_article_result"}),"nntp_article_result").await;
351 let code = match (verb.as_str(), accepted) {
352 ("POST", true) => 240,
353 ("POST", false) => 441,
354 ("IHAVE", true) => 235,
355 ("IHAVE", false) => 437,
356 ("TAKETHIS", true) => 239,
357 _ => 439,
358 };
359 write(
360 writer,
361 &format!(
362 "{code} {}\r\n",
363 id.unwrap_or(if accepted {
364 "Article accepted"
365 } else {
366 "Article rejected"
367 })
368 ),
369 )
370 .await?;
371 Ok(true)
372}