netget/client/gemini/
wire.rs1use anyhow::{bail, ensure, Context, Result};
3use serde_json::{json, Value};
4use std::time::Duration;
5use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt};
6pub const DEADLINE: Duration = Duration::from_secs(30);
7pub const MAX_BODY_BYTES: usize = 1024 * 1024;
8pub const MAX_LINES: usize = 8192;
9pub const MAX_HEADER_BYTES: usize = 1029;
10#[derive(Debug)]
11pub struct Request {
12 pub url: url::Url,
13 pub bytes: Vec<u8>,
14}
15impl Request {
16 pub fn from_action(v: &Value) -> Result<Self> {
17 let raw = v["url"].as_str().context("Missing Gemini URL")?;
18 ensure!(
19 !crate::utils::sanitize::has_controls(&raw),
20 "URL contains control characters"
21 );
22 crate::server::gemini::wire::parse_request(raw)
23 .map_err(|e| anyhow::anyhow!("Invalid Gemini URL: {e:?}"))?;
24 let mut url = url::Url::parse(raw)?;
25 if let Some(input) = v.get("input") {
26 let input = input.as_str().context("input must be text")?;
27 ensure!(input.len() <= 1024, "Input too long");
28 let mut query = String::new();
29 for b in input.bytes() {
30 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
31 query.push(b as char)
32 } else {
33 query.push_str(&format!("%{b:02X}"));
34 }
35 }
36 url.set_query(Some(&query));
37 }
38 ensure!(url.as_str().len() <= 1024, "Gemini URL exceeds 1024 bytes");
39 ensure!(url.port().unwrap_or(1965) > 0, "Invalid Gemini port");
40 Ok(Self {
41 bytes: format!("{}\r\n", url.as_str()).into_bytes(),
42 url,
43 })
44 }
45}
46pub async fn response<R: AsyncBufRead + Unpin>(r: &mut R, request: &Request) -> Result<Value> {
47 let mut header = Vec::new();
48 loop {
49 let buf = r.fill_buf().await?;
50 ensure!(!buf.is_empty(), "Truncated Gemini header");
51 let n = buf
52 .iter()
53 .position(|b| *b == b'\n')
54 .map(|n| n + 1)
55 .unwrap_or(buf.len());
56 ensure!(
57 header.len() + n <= MAX_HEADER_BYTES,
58 "Gemini header exceeds1029 bytes"
59 );
60 header.extend_from_slice(&buf[..n]);
61 r.consume(n);
62 if header.ends_with(b"\n") {
63 break;
64 }
65 }
66 ensure!(header.ends_with(b"\r\n"), "Gemini requires CRLF");
67 header.truncate(header.len() - 2);
68 ensure!(
69 header.len() >= 3
70 && (b'1'..=b'6').contains(&header[0])
71 && header[1].is_ascii_digit()
72 && header[2] == b' ',
73 "Malformed Gemini status"
74 );
75 let status = (header[0] - b'0') * 10 + header[1] - b'0';
76 let meta = std::str::from_utf8(&header[3..])?;
77 ensure!(
78 !crate::utils::sanitize::has_controls(&meta),
79 "Control character in Gemini meta"
80 );
81 let mut out = json!({"status":status,"meta":meta});
82 match status / 10 {
83 1 => {
84 out["kind"] = json!("input");
85 out["sensitive"] = json!(status == 11);
86 }
87 2 => {
88 let media = if meta.is_empty() {
89 "text/gemini; charset=utf-8"
90 } else {
91 meta
92 };
93 let mime = media
94 .split(';')
95 .next()
96 .unwrap_or("")
97 .trim()
98 .to_ascii_lowercase();
99 ensure!(
100 mime.starts_with("text/"),
101 "Unsupported non-text Gemini MIME type: {mime}"
102 );
103 let mut ascii = false;
104 for field in media.split(';').skip(1) {
105 if let Some((name, value)) = field.split_once('=') {
106 if name.trim().eq_ignore_ascii_case("charset") {
107 let value = value.trim().trim_matches('"');
108 ensure!(
109 value.eq_ignore_ascii_case("utf-8")
110 || value.eq_ignore_ascii_case("us-ascii"),
111 "Unsupported text charset"
112 );
113 ascii = value.eq_ignore_ascii_case("us-ascii");
114 }
115 }
116 }
117 let mut body = Vec::new();
118 r.take((MAX_BODY_BYTES + 1) as u64)
119 .read_to_end(&mut body)
120 .await?;
121 ensure!(body.len() <= MAX_BODY_BYTES, "Gemini body exceeds1MiB");
122 ensure!(
123 !ascii || body.is_ascii(),
124 "Non-ASCII body declares us-ascii"
125 );
126 let text = String::from_utf8(body).context("Gemini text is not UTF-8")?;
127 out["kind"] = json!("success");
128 out["mime_type"] = json!(mime);
129 if mime == "text/gemini" {
130 out["lines"] = gemtext(&text, &request.url)?;
131 }
132 out["text"] = json!(text);
133 }
134 3 => {
135 ensure!(!meta.is_empty(), "Redirect has no target");
136 let target = request.url.join(meta).context("Invalid redirect target")?;
137 out["kind"] = json!("redirect");
138 out["url"] = json!(target.as_str());
139 out["permanent"] = json!(status == 31);
140 }
141 4 => {
142 out["kind"] = json!("temporary_failure");
143 if status == 44 {
144 out["retry_after_secs"] =
145 json!(meta.parse::<u64>().context("Invalid slow-down delay")?);
146 }
147 }
148 5 => out["kind"] = json!("permanent_failure"),
149 6 => out["kind"] = json!("certificate_required"),
150 _ => bail!("Invalid status class"),
151 }
152 Ok(out)
153}
154pub fn gemtext(text: &str, base: &url::Url) -> Result<Value> {
155 let mut out = Vec::new();
156 let mut pre: Option<(String, Vec<&str>)> = None;
157 for (index, line) in text.lines().enumerate() {
158 ensure!(index < MAX_LINES, "Too many gemtext lines");
159 if let Some(alt) = line.strip_prefix("```") {
160 if let Some((alt, lines)) = pre.take() {
161 out.push(json!({"type":"preformatted","alt":alt,"text":lines.join("\n")}));
162 } else {
163 pre = Some((alt.into(), Vec::new()));
164 }
165 continue;
166 }
167 if let Some((_, lines)) = &mut pre {
168 lines.push(line);
169 continue;
170 }
171 if let Some(link) = line.strip_prefix("=>") {
172 let link = link.trim_start();
173 let end = link.find(char::is_whitespace).unwrap_or(link.len());
174 let raw = &link[..end];
175 ensure!(!raw.is_empty(), "Empty gemtext link");
176 let target = base.join(raw).context("Invalid gemtext link")?;
177 out.push(json!({"type":"link","url":target.as_str(),"text":link[end..].trim()}));
178 } else if let Some(text) = line.strip_prefix("###") {
179 out.push(json!({"type":"heading3","text":text.trim_start()}));
180 } else if let Some(text) = line.strip_prefix("##") {
181 out.push(json!({"type":"heading2","text":text.trim_start()}));
182 } else if let Some(text) = line.strip_prefix('#') {
183 out.push(json!({"type":"heading1","text":text.trim_start()}));
184 } else if let Some(text) = line.strip_prefix("* ") {
185 out.push(json!({"type":"list","text":text}));
186 } else if let Some(text) = line.strip_prefix('>') {
187 out.push(json!({"type":"quote","text":text.trim_start()}));
188 } else {
189 out.push(json!({"type":"text","text":line}));
190 }
191 }
192 if let Some((alt, lines)) = pre {
193 out.push(json!({"type":"preformatted","alt":alt,"text":lines.join("\n")}));
194 }
195 Ok(json!(out))
196}