Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 110 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" />
```

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:
- `<Dioscuri/>` for normal content and error messages
- `<DioscuriPrompt/>` for input prompt
- `<DioscuriInput/>` for input field

**Two possibilities**:
1. If the website you are on needs user input, Dioscuri will inject into `<DioscuriPrompt/>` and `<DioscuriInput/>`, and ignore `<Dioscuri>`
2. If the website you are on just serves content, Dioscuri will inject into `<Dioscuri>`, and ignore the other tags.

For example, consider this `body.html`:
``` html
<body>
<h1>Hello, world!</h1>
<Dioscuri/>
<p>======</p>
<h3><DioscuriPrompt></h3>
<p>++++++</p>
<DioscuriInput>
<h1 class="my_custom_class">Goodbye, world!</h1>
</body>
```

If the website you talk to just returns a webpage:

```html
<body>
<h1>Hello, world!</h1>
<h1>Injected Content</h1>
<h2>The injected content contains...</h2>
<p>Whatever the Gemini website serves,</p>
<a>Including links!</a>
<p>======</p>
<p>++++++</p>
<h1 class="my_custom_class">Goodbye, world!</h1>
</body>
```

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

``` html
<body>
<h1>Hello, world!</h1>
<p>======</p>
<h3>Enter your query!</h3>
<p>++++++</p>
<form method="get"><label><input type="text" name="query"></label><input type="submit" value="Submit"></form>
<h1 class="my_custom_class">Goodbye, world!</h1>
</body>
```

For now, `<DioscuriInput/>` is not customizable. It will always be injected with `<form method="get"><label><input type="text" name="query"></label><input type="submit" value="Submit"></form>`

#### 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.

Expand Down
193 changes: 147 additions & 46 deletions src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,42 @@ 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 = "
<h1>Welcome to Project Dioscuri!</h1>
<h2>A hackable, accessible Gemini client.</h2>
<p>This is the default homepage. You can drop</p>
<p>Try browsing with some of these links:</p>
<ul>
<li><a href=\"/geminiprotocol.net/\">geminiprotocol.net (Gemini Protocol)</a></li>
<li><a href=\"/kennedy.gemi.dev\">kennedy.gemi.dev (Kennedy Search Engine)</a></li>
<li><a href=\"/bbs.geminispace.org\">bbs.geminispace.org (Gemini BBS)</a></li>
</ul>
";

static HTML_DEFAULT_INPUT: &str = "
<form method=\"get\"><label><input type=\"text\" name=\"query\"></label><input type=\"submit\" value=\"Submit\"></form>
";

static COMPONENT_MAIN: &str = "<Dioscuri/>";
static COMPONENT_PROMPT: &str = "<DioscuriPrompt/>";
static COMPONENT_INPUT: &str = "<DioscuriInput/>";


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};

/// 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());
Expand All @@ -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<PathBuf, std::io::Error> {
/// 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<String>{
// Try to get the user's home directory
let home_dir = match dirs::home_dir() {
Some(path) => path,
None => {
return Html("<h1>Error!</h1><p>Could not determine home directory.</p>".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("
<h1>Welcome to Project Dioscuri!</h1>
<h2>A hackable, accessible Gemini client.</h2>
<p>This is the default homepage.</p>
<p>Try browsing with some of these links:</p>
<ul>
<li><a href=\"/geminiprotocol.net/\">geminiprotocol.net (Gemini Protocol)</a></li>
<li><a href=\"/kennedy.gemi.dev\">kennedy.gemi.dev (Kennedy Search Engine)</a></li>
<li><a href=\"/bbs.geminispace.org\">bbs.geminispace.org (Gemini BBS)</a></li>
</ul>
"
.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 <Dioscuri/> or <DioscuriPrompt/> or <DioscuriInput/> 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<String>{
return Html(format!("{}{}",load_header(),load_home()));
}

/// Given a query {foo}={bar}, where bar can include more queries,
Expand Down Expand Up @@ -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}<br><form method=\"get\"><label><input type=\"text\" name=\"query\"></label><input type=\"submit\" value=\"Submit\"></form>"
);
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}<br><form method=\"get\"><label><input type=\"text\" name=\"query\"></label><input type=\"submit\" value=\"Submit\"></form>"
);
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<String>) -> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Loading