diff --git a/readme.md b/readme.md index 4b67dd2..e60555e 100644 --- a/readme.md +++ b/readme.md @@ -4,21 +4,128 @@ Yet another [Gemini](http://portal.mozz.us/gemini/geminiprotocol.net/) protocol Dioscuri is currently in **Beta**. - this client supports basic surfing and browsing :white_check_mark: +- this client supports browser customizability! [See: User Hacking Guide](#user-hacking-guide) - this client DOES NOT support user-state management yet (i.e. you provide your own cert) :x: -- this client DOES NOT support any customizability yet :x: ## Vision Dioscuri aims to be a hackable, accessible way to access hobbyist network protocols such as Gemini. **Hackable** -- Eventually, Dioscuri aims to allow you to roll your own html,css,js and themes to customize your experience. +- Dioscuri aims to allow you to roll your own html,css,js and themes to customize your experience. Similar vibes to old school Netscape or building your own Blogger website! **Accessible** - No need to install fancy GUI dependencies such as wxWidgets or curses or whatever which you won't use in a month's time - Simply access Gemini from the convenience of a good-ol regular web browser! -## Architecture +# User Hacking Guide + +Dioscuri works out the box, but you can customize it! + +## Customizing the interface + +Dioscuri will always load the following files from `~/.dioscuri/browser/` in this order: +1. `head.html` +2. `body.html` if not homepage, else `home.html` + +If `head.html` does not exist, then it will be skipped. +If `body.html` does not exist, then Dioscuri will just serve the HTML rendered content directly. +If `home.html` does not exist, then Dioscuri will serve a default homepage. + +### hacking `head.html` and using custom resources + +Typically, in `head.html`, you would import stylesheets and various `js` to suit your browsing needs. +HTML resources that use http(s) links will work out of the box. For example: +``` html + +``` + +But if you want to serve content **locally**, you must do the following: +1. Place your file content in `~/.dioscuri/browser/{your_file_path}` +2. reference it via `href="/.src/{your_file_path}` + +Strict path matching is used. +- `href=".src/my_file.css"` will fail +- `href="/.src/my_file.css"` will work + +For example, for `~/.dioscuri/browser/stylesheets/style.css`, use `href="/.src/stylesheets/style.css"` in your link tag. + +### Diosuri components +Dioscuri will automatically convert gemini protocol content into HTML. +It will then search `body.html` for component HTML tags to inject HTML content there! + +Dioscuri will inject content in these components: +- `` for normal content and error messages +- `` for input prompt +- `` for input field + +**Two possibilities**: +1. If the website you are on needs user input, Dioscuri will inject into `` and ``, and ignore `` +2. If the website you are on just serves content, Dioscuri will inject into ``, and ignore the other tags. + +For example, consider this `body.html`: +``` html + +

Hello, world!

+ +

======

+

+

++++++

+ +

Goodbye, world!

+ +``` + +If the website you talk to just returns a webpage: + +```html + +

Hello, world!

+

Injected Content

+

The injected content contains...

+

Whatever the Gemini website serves,

+Including links! +

======

+

++++++

+

Goodbye, world!

+ +``` + +If the webpage requires your input (maybe you are using a search engine), then: + +``` html + +

Hello, world!

+

======

+

Enter your query!

+

++++++

+
+

Goodbye, world!

+ +``` + +For now, `` is not customizable. It will always be injected with `
` + +#### Missing Components +If components are missing, + +### Custom form content + +If you really want to, you can add your own form content to interact directly with Dioscuri (specifically, the HTTP proxy). +Note that the gemini protocol treats queries as user input. + +So, if you wanted to send user input for a page `foo.net/hello`, your form should send a GET request to the proxy as such: +`GET http://localhost:port/foo.net/hello?q=Your User Input!`. +Gemini protocol does not care about the key, just the value. +Behind the scenes, the URL will be reformatted to `foo.net/hello?Your User Input!`. Notice that the form key "q" was thrown away. + +If you wanted to go directly to a particular website, your form should send a GET request to the proxy as such: +`GET http://localhost:port/goto_this_site.com`. +Dioscuri will go to `goto_this_site.com`. You can also just use your browser address bar and type in `http://localhost:port/goto_this_site.com` and it will work the same! + +# Developer Guide + +## Architecture Each box denotes a submodule in the project. diff --git a/src/browser.rs b/src/browser.rs index e47e6c7..b0a7a76 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -2,10 +2,34 @@ use std::{fs, path::PathBuf}; /// The browser module provides a frontend accessible by http /// The following functionality are exposed to other modules -/// start_browser + +// Constants +static HTML_HEAD_FILENAME: &str = "head.html"; +static HTML_BODY_FILENAME: &str = "body.html"; +static HTML_HOME_FILENAME: &str = "home.html"; +static HTML_DEFAULT_HOMEPAGE: &str = " +

Welcome to Project Dioscuri!

+

A hackable, accessible Gemini client.

+

This is the default homepage. You can drop

+

Try browsing with some of these links:

+ +"; + +static HTML_DEFAULT_INPUT: &str = " +
+"; + +static COMPONENT_MAIN: &str = ""; +static COMPONENT_PROMPT: &str = ""; +static COMPONENT_INPUT: &str = ""; + use axum::{ - extract::{Path}, http::Uri, response::Html, routing::get, Router + body::Body, extract::Path, http::{self, Uri}, response::{Html, IntoResponse, Response}, routing::get, Router }; use crate::{gemini::{get_gemini, StatusCode}, gemtext::gemtext_to_html}; @@ -13,7 +37,7 @@ use crate::{gemini::{get_gemini, StatusCode}, gemtext::gemtext_to_html}; /// Starts a HTTP server that acts as a proxy between gemini servers and the user interacting via a browser /// This is a blocking function. pub fn start_browser() { - let _ = _browser_setup_directory(); + _browser_setup_directory(); match tokio::runtime::Runtime::new() { Ok(runtime) => { runtime.block_on(start_axum()); @@ -28,53 +52,83 @@ async fn start_axum(){ let app = Router::new() .route("/", get(get_home)) .route("/{*url}", get(get_normal)) + .route("/.src/{*path}", get(get_resource)) ; - // .route("/gemini/error/{code}", get(get_error)) let listener = tokio::net::TcpListener::bind("0.0.0.0:1965").await.unwrap(); axum::serve(listener, app).await.unwrap(); } -fn _browser_setup_directory() -> Result { +/// Sets up the browser resource directory +fn _browser_setup_directory() { let home_dir = dirs::home_dir().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory does not exist?") - })?; + }).unwrap(); let dioscuri_dir = home_dir.join(".dioscuri/browser"); if !dioscuri_dir.exists() { - fs::create_dir_all(&dioscuri_dir)?; + fs::create_dir_all(&dioscuri_dir).unwrap(); println!("Creating directory: {:?}", dioscuri_dir); } - Ok(dioscuri_dir) } -/// returns .dioscuri/browser/home.html, else a default if not found -async fn get_home() -> Html{ - // Try to get the user's home directory - let home_dir = match dirs::home_dir() { - Some(path) => path, - None => { - return Html("

Error!

Could not determine home directory.

".to_string()); - } - }; +/// Returns ~/.dioscuri/browser +/// Assumes that the folder has been setup properly +fn get_resource_dir() -> PathBuf { + let home_dir = dirs::home_dir().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory does not exist?") + }).unwrap(); + let dioscuri_dir = home_dir.join(".dioscuri/browser"); + if !dioscuri_dir.exists() { + let _ = fs::create_dir_all(&dioscuri_dir); + println!("Creating directory: {:?}", dioscuri_dir); + } + dioscuri_dir +} + +/// Searches the resource directory for body.html +/// if does not exist, return "" +fn load_body() -> String { + let path = get_resource_dir().join(HTML_BODY_FILENAME); + fs::read_to_string(path).unwrap_or_else(|_| "".to_string()) +} + +/// Searches the resource directory for head.html +/// if does not exist, return "" +fn load_header() -> String { + let path = get_resource_dir().join(HTML_HEAD_FILENAME); + fs::read_to_string(path).unwrap_or_else(|_| "".to_string()) +} - // full path to ~/.dioscuri/browser/home.html - let file_path: PathBuf = home_dir.join(".dioscuri/browser/home.html"); - - match fs::read_to_string(&file_path) { - Ok(contents) => Html(contents), - Err(_) => Html(" -

Welcome to Project Dioscuri!

-

A hackable, accessible Gemini client.

-

This is the default homepage.

-

Try browsing with some of these links:

- - " - .to_string()), +/// Searches the resource directory for home.html +/// if does not exist, return HTML_DEFAULT_HOMEPAGE +fn load_home() -> String { + let path = get_resource_dir().join(HTML_HOME_FILENAME); + fs::read_to_string(path).unwrap_or_else(|_| HTML_DEFAULT_HOMEPAGE.to_string()) +} + +/// Loads head.html concat body.html. +/// Once they are concatenated, ensure that the skeleton html contains the injectable tags. +/// If any or or are missing, +/// append them to the back of the skeleton. +/// this ensures that the skeleton html content can properly render all content regardless +/// of the existence of head.html and body.html +fn load_skeleton() -> String { + let mut skeleton = format!("{}{}",load_header(), load_body()); + if !skeleton.contains(COMPONENT_MAIN){ + skeleton.push_str(COMPONENT_MAIN); + } + if !skeleton.contains(COMPONENT_PROMPT){ + skeleton.push_str(COMPONENT_PROMPT); } + if !skeleton.contains(COMPONENT_INPUT){ + skeleton.push_str(COMPONENT_INPUT); + } + skeleton +} + +/// returns .dioscuri/browser/home.html, else a default if not found +async fn get_home() -> Html{ + return Html(format!("{}{}",load_header(),load_home())); } /// Given a query {foo}={bar}, where bar can include more queries, @@ -104,28 +158,75 @@ async fn get_normal( let (status, header, body) = get_gemini(gem_url); match status { StatusCode::Success => { - return Html(gemtext_to_html(body)); + let html = gemtext_to_html(body, url); + let skeleton = load_skeleton(); + // inject the components + let res = skeleton.replace(COMPONENT_MAIN, &html) + .replace(COMPONENT_INPUT, "") + .replace(COMPONENT_PROMPT, ""); + return Html(res) }, StatusCode::InputExpected => { - let str = format!( - "{header}
" - ); - return Html(str); + let skeleton = load_skeleton(); + // inject the components + let res = skeleton.replace(COMPONENT_MAIN, "") + .replace(COMPONENT_INPUT, HTML_DEFAULT_INPUT) + .replace(COMPONENT_PROMPT, &header); + return Html(res); }, StatusCode::InputSensitive => { - let str = format!( - "{header}
" - ); - return Html(str); + let skeleton = load_skeleton(); + // inject the components + let res = skeleton.replace(COMPONENT_MAIN, "") + .replace(COMPONENT_INPUT, HTML_DEFAULT_INPUT) + .replace(COMPONENT_PROMPT, &header); + return Html(res); }, _ => { - // error - println!("{},{}", header, status.as_str()); - return Html("oops!".to_string()) + let skeleton = load_skeleton(); + // inject the components + let res = skeleton.replace(COMPONENT_MAIN, &header) + .replace(COMPONENT_INPUT, "") + .replace(COMPONENT_PROMPT, ""); + return Html(res); } } } +/// Searches ~/.dioscuri/browser/{my_path_to_file} by extracting my_path_to_file +/// The filepath must only exist within the browser/ folder for security concerns +async fn get_resource(Path(filepath): Path) -> impl IntoResponse { + println!("Requested resouce: {}", filepath); + // get the resource directory first + let parent_dir = get_resource_dir(); + let resource_path = parent_dir.join(filepath); + + // resolve symlinks and relative components + let Ok(resource_path_canon) = resource_path.canonicalize() else { + return (http::StatusCode::NOT_FOUND, "Invalid file path").into_response(); + }; + + let Ok(parent_canon) = parent_dir.canonicalize() else { + return (http::StatusCode::INTERNAL_SERVER_ERROR, "Dioscuri's browser resource folder is invalid!").into_response(); + }; + + // resource should be a child of the browser folder + if !resource_path_canon.starts_with(&parent_canon) { + return (http::StatusCode::FORBIDDEN, "Access denied").into_response(); + } + + // read and serve the resource + match fs::read(&resource_path_canon) { + Ok(contents) => { + Response::builder() + .status(http::StatusCode::OK) + .body(Body::from(contents)) + .unwrap() + } + Err(_) => (http::StatusCode::NOT_FOUND, "File not found").into_response(), + } +} + #[cfg(test)] mod tests { use crate::browser::strip_first_url_query_key; diff --git a/src/gemini.rs b/src/gemini.rs index 6cdd685..44810b5 100644 --- a/src/gemini.rs +++ b/src/gemini.rs @@ -236,7 +236,7 @@ pub fn get_gemini(url: String) -> (StatusCode, String, String){ let final_url = format!("gemini://{}", url_stripped); let request = client_build_request_str(final_url.clone()); - println!("req:{}",request.clone()); + if let Err(e) = stream.write_all(request.as_bytes()) { return (StatusCode::FailureClient, "".to_string(), format!("Error while writing to TLS stream!\n{}", e).to_string()) } diff --git a/src/gemtext.rs b/src/gemtext.rs index 0280807..4c97d85 100644 --- a/src/gemtext.rs +++ b/src/gemtext.rs @@ -1,8 +1,11 @@ +use url::Url; + /// Given a gemtext string, perform some manipulations and return the desired result /// Takes in a gemtext string, converts it to md then converts it to html -pub fn gemtext_to_html(gemtext: String) -> String { - let md = gemtext_to_md(gemtext); +/// baseurl is used to relativize all links the baseurl provided. leave as empty string if not needed +pub fn gemtext_to_html(gemtext: String, url: String) -> String { + let md = gemtext_to_md(gemtext, url); return markdown::to_html(&md); } @@ -10,41 +13,14 @@ pub fn gemtext_to_html(gemtext: String) -> String { /// See: https://portal.mozz.us/gemini/geminiprotocol.net/docs/gemtext-specification.gmi /// Fortunately, gemtext is close enough to markdown to allow minimal changes. /// All lines will be appended with a trailing \n -fn gemtext_to_md(gemtext: String) -> String { +fn gemtext_to_md(gemtext: String, _baseurl: String) -> String { let mut result = String::new(); for line in gemtext.lines() { let trimmed = line.trim_start(); - // println!("{}",relative_url); // Convert links to md links if trimmed.starts_with("=>") { - // Remove the leading "=>" - let rest = trimmed[2..].trim(); - - // Split into URL and optional label - let mut parts = rest.splitn(2, char::is_whitespace); - let mut url = parts.next().unwrap_or("").trim().to_string(); - let label = parts.next().unwrap_or("").trim().to_string(); - - // Relative link rule hacking - // relative links start with '/'. - // to transform into md friendly relative link, simply remove the '/' - // redirects/new pages start with 'gemini://' - // to transform into a new link, replace 'gemini://' with '/' - // Replace gemini:// with / - if url.starts_with('/'){ - if let Some(stripped_url) = url.strip_prefix('/') { - url = stripped_url.to_string(); - } - }else if let Some(after_scheme) = url.strip_prefix("gemini://") { - url = format!("/{}", after_scheme); - } - - if label.is_empty() { - result.push_str(&format!("[{}]({})\n\n", url, url)); - } else { - result.push_str(&format!("[{}]({})\n\n", label, url)); - } + result.push_str(&format!("{}\n\n",resolve_links(trimmed.to_string(), _baseurl.clone()))); } else { result.push_str(&format!("{}\n\n", trimmed)); // plain paragraph } @@ -53,26 +29,116 @@ fn gemtext_to_md(gemtext: String) -> String { } +fn resolve_links(link: String, url: String) -> String { + // Ensure URL is a proper Gemini URL for resolution + let base_url = Url::parse(&format!("gemini://{}", url)) + .unwrap_or_else(|_| Url::parse("gemini://tmp/").unwrap()); + + // Remove leading "=>" + let trimmed = link.trim_start().strip_prefix("=>").unwrap_or(&link).trim(); + + // Split into href and optional label + let mut parts = trimmed.splitn(2, char::is_whitespace); + let raw_href = parts.next().unwrap_or(""); + let label = parts.next().unwrap_or("").trim(); + + // resolve urls + let resolved_url = base_url.join(raw_href); + match resolved_url { + Ok(url) if url.scheme() == "gemini" => { + let host = url.host_str().unwrap_or("invalid"); + let path = url.path(); + let mut proxy_path = format!("/{host}{}", path); + if let Some(q) = url.query() { + proxy_path.push('?'); + proxy_path.push_str(q); + } + + let display = if label.is_empty() { + proxy_path.clone() + } else { + label.to_string() + }; + + format!("[{}]({})", display, proxy_path) + } + _ => { // http(s) or other protocol link + let display = if label.is_empty() { raw_href } else { label }; + format!("[{}]({})", display, raw_href) + } + } +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn test_basic_heading_and_paragraph() { - let input = "# Title\nThis is a paragraph."; - let expected = "# Title\n\nThis is a paragraph.\n\n"; - assert_eq!(gemtext_to_md(input.to_string()), expected); - } - #[test] fn test_links() { - let in0 = "hello\n=>/relative/link.gmi"; - let out0 = "hello\n\n[relative/link.gmi](relative/link.gmi)\n\n"; - assert_eq!(gemtext_to_md(in0.to_string()), out0); - let in1 = "hello\n=>/relative/link.gmi my custom / text!"; - let out1 = "hello\n\n[my custom / text!](relative/link.gmi)\n\n"; - println!("{}",gemtext_to_md(in1.to_string())); - assert_eq!(gemtext_to_md(in1.to_string()), out1); + fn check(link: &str, base: &str, expected: &str) { + assert_eq!(resolve_links(link.to_string(), base.to_string()), expected); + } + + check( + "=> /index.gmi Welcome", + "gemi.dev/cgi-bin/wp.cgi/a?b=c", + "[Welcome](/gemi.dev/index.gmi)" + ); + + check( + "=> foo.gmi Foo", + "gemi.dev/cgi-bin/wp.cgi/a?b=c", + "[Foo](/gemi.dev/cgi-bin/wp.cgi/foo.gmi)" + ); + + check( + "=> ../bar.gmi Go up", + "gemi.dev/cgi-bin/wp.cgi/a", + "[Go up](/gemi.dev/cgi-bin/bar.gmi)" + ); + + check( + "=> gemini://example.com/docs/ External", + "gemi.dev/docs/", + "[External](/example.com/docs/)" + ); + + check( + "=> https://google.com Google", + "gemi.dev/docs/", + "[Google](https://google.com)" + ); + + check( + "=> help", + "gemi.dev/docs/", + "[/gemi.dev/docs/help](/gemi.dev/docs/help)" + ); + + check( + "=> /help", + "gemi.dev/docs/", + "[/gemi.dev/help](/gemi.dev/help)" + ); + + check( + "=> /help/me/find/this.gmi foo bar", + "gemi.dev/docs/", + "[foo bar](/gemi.dev/help/me/find/this.gmi)" + ); + + check( + "=> help", + "gemi.dev/docs/tutorial.gmi", + "[/gemi.dev/docs/help](/gemi.dev/docs/help)" + ); + + check( + "=> /cgi-bin/wp.cgi/view?Siege+of+Breteuil Siege of Breteuil", + "gemi.dev/cgi-bin/wp.cgi/featured", + "[Siege of Breteuil](/gemi.dev/cgi-bin/wp.cgi/view?Siege+of+Breteuil)" + ); + } } diff --git a/src/tofu.rs b/src/tofu.rs index ea68a59..ab7bc83 100644 --- a/src/tofu.rs +++ b/src/tofu.rs @@ -63,7 +63,7 @@ pub fn tofu_handle_certificate(cert: Certificate) -> Result<(), ()> { // println!("Certificate does not exist in the store. Adding to trust store (TOFU)..."); fs::create_dir_all(&cert_dir).unwrap(); fs::write(&target_cert_path, &cert_der).unwrap(); - println!("New certificate for {} added to trust store.", domain_str); + // println!("New certificate for {} added to trust store.", domain_str); return Ok(()); // Add and accept the new certificate } },