♻️ Simple & Efficient Gemini-to-HTTP Proxy fuwn.net
proxy gemini-protocol protocol gemini http rust
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 438 lines 13 kB view raw
1pub mod configuration; 2 3use { 4 crate::{ 5 environment::ENVIRONMENT, 6 url::{from_path as url_from_path, matches_pattern}, 7 }, 8 actix_web::{Error, HttpResponse}, 9 std::{fmt::Write, time::Instant}, 10}; 11 12const CSS: &str = include_str!("../default.css"); 13 14#[derive(serde::Deserialize)] 15pub struct InputSubmission { 16 input: String, 17 target: Option<String>, 18} 19 20fn html_escape(input: &str) -> String { 21 input 22 .replace('&', "&amp;") 23 .replace('"', "&quot;") 24 .replace('<', "&lt;") 25 .replace('>', "&gt;") 26} 27 28#[allow(clippy::future_not_send, clippy::too_many_lines)] 29pub async fn default( 30 http_request: actix_web::HttpRequest, 31 input_submission: Option<actix_web::web::Form<InputSubmission>>, 32) -> Result<HttpResponse, Error> { 33 if ["/proxy", "/proxy/", "/x", "/x/", "/raw", "/raw/", "/nocss", "/nocss/"] 34 .contains(&http_request.path()) 35 { 36 return Ok(HttpResponse::Ok() 37 .content_type("text/html") 38 .body(r"<h1>September</h1> 39<p>This is a proxy path. Specify a Gemini URL without the protocol (<code>gemini://</code>) to proxy it.</p> 40<p>To proxy <code>gemini://fuwn.me/uptime</code>, visit <code>https://fuwn.me/proxy/fuwn.me/uptime</code>.</p> 41<p>Additionally, you may visit <code>/raw</code> to view the raw Gemini content, or <code>/nocss</code> to view the content without CSS.</p> 42 ")); 43 } 44 45 let mut configuration = configuration::Configuration::new(); 46 let submitted_input = 47 if *http_request.method() == actix_web::http::Method::POST { 48 input_submission.as_ref().map(|submission| submission.input.clone()) 49 } else { 50 None 51 }; 52 let submitted_target = 53 if *http_request.method() == actix_web::http::Method::POST { 54 input_submission.as_ref().and_then(|submission| submission.target.clone()) 55 } else { 56 None 57 }; 58 let mut url = match url_from_path( 59 &format!("{}{}", http_request.path(), { 60 if !http_request.query_string().is_empty() 61 || http_request.uri().to_string().ends_with('?') 62 { 63 format!("?{}", http_request.query_string()) 64 } else { 65 String::new() 66 } 67 }), 68 false, 69 &mut configuration, 70 ) { 71 Ok(url) => url, 72 Err(e) => { 73 return Ok( 74 HttpResponse::BadRequest() 75 .content_type("text/plain") 76 .body(format!("{e}")), 77 ); 78 } 79 }; 80 81 if let Some(target) = submitted_target { 82 if let Ok(parsed_target) = url::Url::parse(&target) { 83 if parsed_target.scheme() == "gemini" { 84 url = parsed_target; 85 } 86 } 87 } 88 89 if let Some(input) = submitted_input { 90 let input = input 91 .replace("\r\n", "\n") 92 .replace('\r', "\n") 93 .replace('\t', "%09") 94 .replace('\n', "%0A"); 95 96 url.set_query(Some(&input)); 97 } 98 99 let mut timer = Instant::now(); 100 let mut response = match germ::request::request(&url).await { 101 Ok(response) => response, 102 Err(e) => { 103 return Ok(HttpResponse::Ok().body(e.to_string())); 104 } 105 }; 106 let mut redirect_response_status = None; 107 let mut redirect_url = None; 108 109 if *response.status() == germ::request::Status::PermanentRedirect 110 || *response.status() == germ::request::Status::TemporaryRedirect 111 { 112 redirect_response_status = Some(*response.status()); 113 redirect_url = Some( 114 url::Url::parse(&if response.meta().starts_with('/') { 115 format!( 116 "gemini://{}{}", 117 url.domain().unwrap_or_default(), 118 response.meta() 119 ) 120 } else { 121 response.meta().to_string() 122 }) 123 .unwrap(), 124 ); 125 response = 126 match germ::request::request(&redirect_url.clone().unwrap()).await { 127 Ok(response) => response, 128 Err(e) => { 129 return Ok(HttpResponse::Ok().body(e.to_string())); 130 } 131 } 132 } 133 134 let response_time_taken = timer.elapsed(); 135 let meta = germ::meta::Meta::from_string(response.meta().to_string()); 136 let charset = meta 137 .parameters() 138 .get("charset") 139 .map_or_else(|| "utf-8".to_string(), ToString::to_string); 140 let language = 141 meta.parameters().get("lang").map_or_else(String::new, ToString::to_string); 142 143 timer = Instant::now(); 144 145 if response.meta().starts_with("image/") { 146 if let Some(content_bytes) = &response.content_bytes() { 147 return Ok( 148 HttpResponse::build(actix_web::http::StatusCode::OK) 149 .content_type(response.meta().as_ref()) 150 .body(content_bytes.to_vec()), 151 ); 152 } 153 } 154 155 if *response.status() == germ::request::Status::Input 156 || *response.status() == germ::request::Status::SensitiveInput 157 { 158 if configuration.is_raw() { 159 return Ok( 160 HttpResponse::Ok() 161 .content_type(format!("text/plain; charset={charset}")) 162 .body(response.meta().to_string()), 163 ); 164 } 165 166 let mut html_context = format!( 167 r#"<!DOCTYPE html><html{}><head><meta name="viewport" content="width=device-width, initial-scale=1.0">"#, 168 if language.is_empty() { 169 String::new() 170 } else { 171 format!(" lang=\"{language}\"") 172 } 173 ); 174 175 if !configuration.is_no_css() { 176 if let Some(css) = &ENVIRONMENT.css_external { 177 for stylesheet in css.split(',').filter(|s| !s.is_empty()) { 178 let _ = write!( 179 &mut html_context, 180 "<link rel=\"stylesheet\" type=\"text/css\" href=\"{stylesheet}\">", 181 ); 182 } 183 } else { 184 let _ = write!( 185 &mut html_context, 186 r#"<link rel="stylesheet" href="https://latex.vercel.app/style.css"><style>{CSS}</style>"# 187 ); 188 189 if let Some(primary) = &ENVIRONMENT.primary_colour { 190 let _ = write!( 191 &mut html_context, 192 "<style>:root {{ --primary: {primary} }}</style>" 193 ); 194 } else { 195 let _ = write!( 196 &mut html_context, 197 "<style>:root {{ --primary: var(--base0D); }}</style>" 198 ); 199 } 200 } 201 } 202 203 if let Some(favicon) = &ENVIRONMENT.favicon_external { 204 let _ = write!( 205 &mut html_context, 206 "<link rel=\"icon\" type=\"image/x-icon\" href=\"{favicon}\">", 207 ); 208 } 209 210 if let Some(head) = &ENVIRONMENT.head { 211 html_context.push_str(head); 212 } 213 214 let _ = write!( 215 &mut html_context, 216 "<title>{}</title></head><body>", 217 html_escape(&response.meta()), 218 ); 219 220 if !http_request.path().starts_with("/proxy") { 221 if let Some(header) = &ENVIRONMENT.header { 222 let _ = write!( 223 &mut html_context, 224 "<big><blockquote>{header}</blockquote></big>" 225 ); 226 } 227 } 228 229 if let (Some(status), Some(redirected_to)) = 230 (redirect_response_status, redirect_url.clone()) 231 { 232 let _ = write!( 233 &mut html_context, 234 "<blockquote>This page {} redirects to <a \ 235 href=\"{}\">{}</a>.</blockquote>", 236 if status == germ::request::Status::PermanentRedirect { 237 "permanently" 238 } else { 239 "temporarily" 240 }, 241 redirected_to, 242 redirected_to 243 ); 244 } 245 246 let input_url = redirect_url.unwrap_or_else(|| url.clone()); 247 let input_field = 248 if *response.status() == germ::request::Status::SensitiveInput { 249 "<input name=\"input\" type=\"password\" autofocus>" 250 } else { 251 "<textarea name=\"input\" rows=\"8\" autofocus></textarea>" 252 }; 253 let _ = write!( 254 &mut html_context, 255 "<p>{}</p><form method=\"post\" action=\"{}\"><input type=\"hidden\" \ 256 name=\"target\" value=\"{}\">{}<button \ 257 type=\"submit\">Submit</button></form></body></html>", 258 html_escape(&response.meta()), 259 html_escape(&http_request.uri().to_string()), 260 html_escape(input_url.as_ref()), 261 input_field, 262 ); 263 let mut response_builder = HttpResponse::Ok(); 264 265 if *response.status() == germ::request::Status::SensitiveInput { 266 response_builder 267 .insert_header((actix_web::http::header::CACHE_CONTROL, "no-store")); 268 } 269 270 return Ok( 271 response_builder 272 .content_type(format!("text/html; charset={charset}")) 273 .body(html_context), 274 ); 275 } 276 277 let mut html_context = if configuration.is_raw() { 278 String::new() 279 } else { 280 format!( 281 r#"<!DOCTYPE html><html{}><head><meta name="viewport" content="width=device-width, initial-scale=1.0">"#, 282 if language.is_empty() { 283 String::new() 284 } else { 285 format!(" lang=\"{language}\"") 286 } 287 ) 288 }; 289 let gemini_html = 290 crate::html::from_gemini(&response, &url, &configuration).unwrap(); 291 let gemini_title = gemini_html.0; 292 let convert_time_taken = timer.elapsed(); 293 294 if configuration.is_raw() { 295 html_context.push_str( 296 &response.content().as_ref().map_or_else(String::default, String::clone), 297 ); 298 299 return Ok( 300 HttpResponse::Ok() 301 .content_type(format!("{}; charset={charset}", meta.mime())) 302 .body(html_context), 303 ); 304 } 305 306 if configuration.is_no_css() { 307 html_context.push_str(&gemini_html.1); 308 309 return Ok( 310 HttpResponse::Ok() 311 .content_type(format!("text/html; charset={charset}")) 312 .body(html_context), 313 ); 314 } 315 316 if let Some(css) = &ENVIRONMENT.css_external { 317 for stylesheet in css.split(',').filter(|s| !s.is_empty()) { 318 let _ = write!( 319 &mut html_context, 320 "<link rel=\"stylesheet\" type=\"text/css\" href=\"{stylesheet}\">", 321 ); 322 } 323 } else if !configuration.is_no_css() { 324 let _ = write!( 325 &mut html_context, 326 r#"<link rel="stylesheet" href="https://latex.vercel.app/style.css"><style>{CSS}</style>"# 327 ); 328 329 if let Some(primary) = &ENVIRONMENT.primary_colour { 330 let _ = write!( 331 &mut html_context, 332 "<style>:root {{ --primary: {primary} }}</style>" 333 ); 334 } else { 335 let _ = write!( 336 &mut html_context, 337 "<style>:root {{ --primary: var(--base0D); }}</style>" 338 ); 339 } 340 } 341 342 if let Some(favicon) = &ENVIRONMENT.favicon_external { 343 let _ = write!( 344 &mut html_context, 345 "<link rel=\"icon\" type=\"image/x-icon\" href=\"{favicon}\">", 346 ); 347 } 348 349 if ENVIRONMENT.mathjax { 350 html_context.push_str( 351 r#"<script type="text/javascript" id="MathJax-script" async 352 src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"> 353 </script>"#, 354 ); 355 } 356 357 if let Some(head) = &ENVIRONMENT.head { 358 html_context.push_str(head); 359 } 360 361 let _ = write!(&mut html_context, "<title>{gemini_title}</title>"); 362 let _ = write!(&mut html_context, "</head><body>"); 363 364 if !http_request.path().starts_with("/proxy") { 365 if let Some(header) = &ENVIRONMENT.header { 366 let _ = write!( 367 &mut html_context, 368 "<big><blockquote>{header}</blockquote></big>" 369 ); 370 } 371 } 372 373 match response.status() { 374 germ::request::Status::Success => { 375 if let (Some(status), Some(url)) = 376 (redirect_response_status, redirect_url) 377 { 378 let _ = write!( 379 &mut html_context, 380 "<blockquote>This page {} redirects to <a \ 381 href=\"{}\">{}</a>.</blockquote>", 382 if status == germ::request::Status::PermanentRedirect { 383 "permanently" 384 } else { 385 "temporarily" 386 }, 387 url, 388 url 389 ); 390 } 391 392 html_context.push_str(&gemini_html.1); 393 } 394 _ => { 395 let _ = write!(&mut html_context, "<p>{}</p>", response.meta()); 396 } 397 } 398 399 let _ = write!( 400 &mut html_context, 401 "<details>\n<summary>Proxy Information</summary> 402<dl> 403<dt>Original URL</dt><dd><a href=\"{}\">{0}</a></dd> 404<dt>Status Code</dt><dd>{} ({})</dd> 405<dt>Meta</dt><dd><code>{}</code></dd> 406<dt>Capsule Response Time</dt><dd>{} milliseconds</dd> 407<dt>Gemini-to-HTML Time</dt><dd>{} milliseconds</dd> 408</dl> 409<p>This content has been proxied by <a \ 410 href=\"https://github.com/gemrest/september{}\">September ({})</a>.</p> 411</details></body></html>", 412 url, 413 response.status(), 414 i32::from(*response.status()), 415 response.meta(), 416 response_time_taken.as_nanos() as f64 / 1_000_000.0, 417 convert_time_taken.as_nanos() as f64 / 1_000_000.0, 418 format_args!("/tree/{}", env!("VERGEN_GIT_SHA")), 419 env!("VERGEN_GIT_SHA").get(0..5).unwrap_or("UNKNOWN"), 420 ); 421 422 if let Some(plain_texts) = &ENVIRONMENT.plain_text_route { 423 if plain_texts.split(',').any(|r| { 424 matches_pattern(r, http_request.path()) 425 || matches_pattern(r, http_request.path().trim_end_matches('/')) 426 }) { 427 return Ok(HttpResponse::Ok().body( 428 response.content().as_ref().map_or_else(String::default, String::clone), 429 )); 430 } 431 } 432 433 Ok( 434 HttpResponse::Ok() 435 .content_type(format!("text/html; charset={charset}")) 436 .body(html_context), 437 ) 438}