mod config;
mod constants;
mod handlers;
mod network;
use crate::{constants::USER_AGENT, network::ForgeApi};
use config::*;
use constants::{PROJECT_NAME, PROJECT_VERSION, REPO_GMI};
use handlers::templates::PercentEncoded;
use humantime::format_rfc3339;
use network::{error::Error as NetworkError, net::Net, robotstxt::RobotsTxt};
use std::sync::OnceLock;
use windmark::{context::RouteContext, response::Response};
async fn user(cfg: &Config, ctx: RouteContext, net: &Net) -> Response {
let raw_username = ctx.parameters.get("user").expect("Path segment");
let username = PercentEncoded::new_unchecked(raw_username.clone());
route(handlers::user::handler(cfg, net, username).await)
}
async fn user_repo(cfg: &Config, ctx: RouteContext, net: &Net) -> Response {
let raw_username = ctx.parameters.get("user").expect("Path segment");
let username = PercentEncoded::new_unchecked(raw_username.clone());
let raw_repo = ctx.parameters.get("repo").expect("Path segment");
let repo = PercentEncoded::new_unchecked(raw_repo.clone());
route(handlers::repo::handler(cfg, net, username, repo).await)
}
async fn branch(cfg: &Config, ctx: RouteContext, net: &Net) -> Response {
let raw_username = ctx.parameters.get("user").expect("Path segment");
let username = PercentEncoded::new_unchecked(raw_username.clone());
let raw_repo = ctx.parameters.get("repo").expect("Path segment");
let repo = PercentEncoded::new_unchecked(raw_repo.clone());
let raw_branch = ctx.parameters.get("branch").expect("Path segment");
let branch = PercentEncoded::new_unchecked(raw_branch.clone());
let item_path = PercentEncoded::new_unchecked(String::new());
route(handlers::file::handler(cfg, net, username, repo, branch, item_path).await)
}
async fn file(cfg: &Config, ctx: RouteContext, net: &Net) -> Response {
let raw_username = ctx.parameters.get("user").expect("Path segment");
let username = PercentEncoded::new_unchecked(raw_username.clone());
let raw_repo = ctx.parameters.get("repo").expect("Path segment");
let repo = PercentEncoded::new_unchecked(raw_repo.clone());
let raw_branch = ctx.parameters.get("branch").expect("Path segment");
let branch = PercentEncoded::new_unchecked(raw_branch.clone());
let raw_item_path = ctx.parameters.get("path").expect("Path segment");
let item_path = PercentEncoded::new_unchecked(raw_item_path.clone());
route(handlers::file::handler(cfg, net, username, repo, branch, item_path).await)
}
#[windmark::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg: &Config = config();
static NET: OnceLock<Net> = OnceLock::new();
let api = cfg.forge_api();
let base_url = &cfg.forge_url;
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.expect("valid client");
let restrictions = RobotsTxt::fetch_from(client, &api, base_url).await;
NET.set(Net::new(restrictions)).ok().expect("assigned once");
let net = NET.get().expect("assigned");
cfg.forge_api().check_access_token(net).await?;
let kind = cfg.forge_api().kind().to_string();
match cfg.forge_api().get_version(net).await {
Ok(server_version) => {
let version = server_version.version;
println!("↘ Upstream: {kind} {version}");
}
Err(NetworkError::Unauthorized) => {
println!(
"↘⚠️ Warning: We are not authorized to query metadata of this {kind} instance. Some routes may return errors."
);
}
Err(NetworkError::Throttled(None)) => {
println!(
"↘⚠️ Warning: The upstream asked us to wait a while first. Some routes may return errors."
);
}
Err(NetworkError::Throttled(Some(retry_after))) => {
let time = format_rfc3339(retry_after);
println!(
"↘⚠️ Warning: The upstream asked us to wait until after {time}. Some routes may return errors."
);
}
Err(NetworkError::Restricted) => {
println!(
"↘⚠️ Warning: The upstream asked us not to go here. Some routes may return errors."
);
}
Err(err) => {
println!("↘⚠️ Error: {err}");
std::process::exit(1);
}
};
println!("Serving capsule on port {}", cfg.port);
windmark::router::Router::new()
.set_port(cfg.port.into())
.set_private_key_file(format!("{}/key.pem", cfg.certs_dir))
.set_certificate_file(format!("{}/cert.pem", cfg.certs_dir))
.add_header(move |ctx: RouteContext| {
if ctx.url.path() == "/robots.txt" {
return String::new();
}
format!("# {kind} Proxy")
})
.add_footer(|ctx: RouteContext| {
if ctx.url.path() == "/robots.txt" {
return String::new();
}
let base: &str = ctx.url.path().split("/").collect::<Vec<&str>>().get(1).unwrap_or(&"/");
let should_list_users = cfg.forge_list_users;
let mode_dest: &str = if base == "users" {
"=> / Recent Activity\n\n"
} else if should_list_users {
"=> /users Users\n\n"
} else {
"\n"
};
format!("\n==========================\n{mode_dest}=> {REPO_GMI} Powered by {PROJECT_NAME} v{PROJECT_VERSION}\n")
})
.mount("/", async |_| route(handlers::root::handler(cfg, net).await))
.mount("/robots.txt", |_| route(handlers::robotstxt::handler(cfg)))
.mount("/users", async |_| route(handlers::users::handler(cfg, net).await))
.mount("/users/", async |_| route(handlers::users::handler(cfg, net).await))
.mount("/:user", |ctx| user(cfg, ctx, net))
.mount("/:user/", |ctx| user(cfg, ctx, net))
.mount("/:user/:repo", |ctx| user_repo(cfg, ctx, net))
.mount("/:user/:repo/", |ctx| user_repo(cfg, ctx, net))
.mount("/:user/:repo/src/branch/:branch", |ctx| branch(cfg, ctx, net))
.mount("/:user/:repo/src/branch/:branch/", |ctx| branch(cfg, ctx, net))
.mount("/:user/:repo/src/branch/:branch/*path", |ctx| {
file(cfg, ctx, net)
})
.set_error_handler(|error| {
let failure_addr = error.url;
println!("↘ Failed to serve {failure_addr}");
Response::temporary_failure("Something went wrong!")
})
.run()
.await
}
fn route(res: Result<Response, NetworkError>) -> Response {
let out = match res {
Ok(res) => res,
Err(err) => err.gemini_response(),
};
println!("↘ {}", describe(&out));
out
}
fn describe(res: &Response) -> String {
match res.status {
10..=19 => format!("GEMINI {} Input", res.status),
20..=29 => format!("GEMINI {} Success", res.status),
30..=39 => format!("GEMINI {} Redirect", res.status),
40..=49 => format!("GEMINI {} Temporary Failure: {}", res.status, res.content),
50..=59 => format!("GEMINI {} Permanent Failure: {}", res.status, res.content),
_ => format!("{} INVALID STATUS CODE", res.status),
}
}