♻️ Simple & Efficient Gemini-to-HTTP Proxy
fuwn.net
proxy
gemini-protocol
protocol
gemini
http
rust
1use {
2 crate::{environment::ENVIRONMENT, url::matches_pattern},
3 germ::ast::Node,
4 std::fmt::Write,
5 url::Url,
6};
7
8fn link_from_host_href(url: &Url, href: &str) -> Option<String> {
9 if href.starts_with("/proxy/") {
10 Some(format!("gemini://{}", href.replace("/proxy/", "")))
11 } else {
12 Some(format!(
13 "gemini://{}{}{}",
14 url.domain()?,
15 { if href.starts_with('/') { "" } else { "/" } },
16 href
17 ))
18 }
19}
20
21fn safe(text: &str) -> String {
22 let is_ordered_list = text.starts_with(|c: char| c.is_ascii_digit())
23 && text.get(1..3) == Some(". ");
24
25 if is_ordered_list {
26 text.to_string()
27 } else {
28 comrak::markdown_to_html(text, &comrak::ComrakOptions::default())
29 .replace("<p>", "")
30 .replace("</p>", "")
31 }
32}
33
34#[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
35pub fn from_gemini(
36 response: &germ::request::Response,
37 url: &Url,
38 configuration: &crate::response::configuration::Configuration,
39) -> Option<(String, String)> {
40 const GEMINI_FRAGMENT: &str =
41 r#"<span class="gemini-fragment">=> </span>"#;
42 let ast_tree = germ::ast::Ast::from_string(
43 response.content().as_ref().map_or_else(String::default, String::clone),
44 );
45 let ast = ast_tree.inner();
46 let mut html = String::new();
47 let mut title = String::new();
48 let mut previous_link = false;
49 let mut previous_link_count = 0;
50 let condense_links =
51 ENVIRONMENT.condense_links.contains(&url.path().to_string())
52 || ENVIRONMENT.condense_links.contains(&"*".to_string());
53 let condensible_headings = ENVIRONMENT
54 .condense_links_at_headings
55 .iter()
56 .map(String::as_str)
57 .collect::<Vec<_>>();
58 let mut in_condense_links_flag_trap = !condensible_headings.is_empty();
59
60 for node in ast {
61 if condensible_headings.contains(&node.to_gemtext().as_str()) {
62 in_condense_links_flag_trap = true;
63 }
64
65 let align_adjacent_links = |html: &str| {
66 if previous_link_count > 0 {
67 html.rfind(GEMINI_FRAGMENT).map_or_else(
68 || html.to_string(),
69 |position| {
70 let mut result =
71 String::with_capacity(html.len() - GEMINI_FRAGMENT.len());
72
73 result.push_str(&html[..position]);
74 result.push_str(&html[position + GEMINI_FRAGMENT.len()..]);
75
76 result
77 },
78 )
79 } else {
80 html.to_string()
81 }
82 };
83
84 if previous_link
85 && (!matches!(node, Node::Link { .. })
86 || (!condense_links && !in_condense_links_flag_trap))
87 {
88 if let Some(next) = ast.iter().skip_while(|n| n != &node).nth(1) {
89 if matches!(next, Node::Link { .. }) || previous_link {
90 html.push_str("<br />");
91 } else {
92 html.push_str("</p>");
93 }
94 } else {
95 html.push_str("</p>");
96 }
97
98 previous_link = false;
99 html = align_adjacent_links(&html);
100 previous_link_count = 0;
101 } else if previous_link {
102 html = align_adjacent_links(&html);
103
104 html.push_str(r#" <span class="gemini-fragment">|</span> "#);
105
106 previous_link_count += 1;
107 } else if !previous_link && matches!(node, Node::Link { .. }) {
108 html.push_str("<p>");
109 }
110
111 match node {
112 Node::Text(text) => {
113 let _ = write!(&mut html, "<p>{}</p>", safe(text));
114 }
115 Node::Link { to, text } => {
116 let mut href = to.clone();
117 let mut surface = false;
118
119 if href.starts_with("./") || href.starts_with("../") {
120 if let Ok(url) = url.join(&href) {
121 href = url.to_string();
122 }
123 }
124
125 if href.contains("://") && !href.starts_with("gemini://") {
126 surface = true;
127 } else if !href.contains("://") && href.contains(':') {
128 // href contains a scheme-like pattern (e.g., mailto:), keep as-is
129 } else if !href.starts_with("gemini://") && !href.starts_with('/') {
130 href = format!(
131 "{}/{}",
132 url.domain().unwrap(),
133 if url.path().ends_with('/') {
134 format!("{}{}", url.path(), href)
135 } else {
136 format!("{}/{}", url.path(), href)
137 }
138 )
139 .replace("//", "/");
140 href = format!("gemini://{href}");
141 } else if href.starts_with('/') || !href.contains("://") {
142 href = link_from_host_href(url, &href)?;
143 }
144
145 if ENVIRONMENT.proxy_by_default
146 && href.contains("gemini://")
147 && !surface
148 {
149 if (configuration.is_proxy())
150 || configuration.is_no_css()
151 || href
152 .trim_start_matches("gemini://")
153 .trim_end_matches('/')
154 .split('/')
155 .collect::<Vec<_>>()
156 .first()
157 .unwrap()
158 != &url.host().unwrap().to_string().as_str()
159 {
160 href = format!(
161 "/{}/{}",
162 if configuration.is_no_css() { "nocss" } else { "proxy" },
163 href.trim_start_matches("gemini://")
164 );
165 } else {
166 href = href.trim_start_matches("gemini://").replacen(
167 &if let Some(host) = url.host() {
168 host.to_string()
169 } else {
170 return None;
171 },
172 "",
173 1,
174 );
175 }
176 }
177
178 if let Some(patterns) = &ENVIRONMENT.keep_gemini {
179 if (href.starts_with('/') || !href.contains("://")) && !surface {
180 let temporary_href = link_from_host_href(url, &href)?;
181 let should_exclude = patterns
182 .iter()
183 .filter(|p| p.starts_with('!'))
184 .any(|p| matches_pattern(&p[1..], &temporary_href));
185
186 if !should_exclude {
187 let should_include = patterns
188 .iter()
189 .filter(|p| !p.starts_with('!'))
190 .any(|p| matches_pattern(p, &temporary_href));
191
192 if should_include {
193 href = temporary_href;
194 }
195 }
196 }
197 }
198
199 if let Some(embed_images) = &ENVIRONMENT.embed_images {
200 if let Some(extension) = std::path::Path::new(&href).extension() {
201 if extension == "png"
202 || extension == "jpg"
203 || extension == "jpeg"
204 || extension == "gif"
205 || extension == "webp"
206 || extension == "svg"
207 {
208 if embed_images == "1" {
209 let _ = writeln!(
210 &mut html,
211 "<p><a href=\"{}\">{}</a> <i>Embedded below</i></p>",
212 href,
213 safe(text.as_ref().unwrap_or(to)),
214 );
215 }
216
217 let _ = writeln!(
218 &mut html,
219 "<p><img src=\"{}\" alt=\"{}\" /></p>",
220 safe(&href),
221 safe(text.as_ref().unwrap_or(to)),
222 );
223
224 continue;
225 }
226 }
227 }
228
229 previous_link = true;
230
231 let _ = write!(
232 &mut html,
233 r#"{}<a href="{}">{}</a>"#,
234 GEMINI_FRAGMENT,
235 href,
236 safe(text.as_ref().unwrap_or(to)).trim(),
237 );
238 }
239 Node::Heading { level, text } => {
240 if !condensible_headings.contains(&node.to_gemtext().as_str()) {
241 in_condense_links_flag_trap = false;
242 }
243
244 if title.is_empty() && *level == 1 {
245 title = safe(text);
246 }
247
248 let _ = write!(
249 &mut html,
250 "<{}>{}</{0}>",
251 match level {
252 1 => "h1",
253 2 => "h2",
254 3 => "h3",
255 _ => "p",
256 },
257 safe(text),
258 );
259 }
260 Node::List(items) => {
261 let _ = write!(
262 &mut html,
263 "<ul>{}</ul>",
264 items
265 .iter()
266 .map(|i| format!("<li>{}</li>", safe(i)))
267 .collect::<Vec<String>>()
268 .join("\n")
269 );
270 }
271 Node::Blockquote(text) => {
272 let _ = write!(&mut html, "<blockquote>{}</blockquote>", safe(text));
273 }
274 Node::PreformattedText { text, .. } => {
275 let new_text = text.strip_suffix('\n').unwrap_or(text);
276 let _ = write!(&mut html, "<pre>{new_text}</pre>");
277 }
278 Node::Whitespace => {}
279 }
280 }
281
282 Some((title, html))
283}