♻️ Simple & Efficient Gemini-to-HTTP Proxy fuwn.net
proxy gemini-protocol protocol gemini http rust
at main 283 lines 8.3 kB view raw
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">=&#62; </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}