+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
}
},