♻️ Simple & Efficient Gemini-to-HTTP Proxy
fuwn.net
proxy
gemini-protocol
protocol
gemini
http
rust
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('&', "&")
23 .replace('"', """)
24 .replace('<', "<")
25 .replace('>', ">")
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}