git-gemini-forge 0.6.0

A simple Gemini server that serves a read-only view of public repositories from a Git forge.
mod config;
mod constants;
mod handlers;
mod network;

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::LazyLock;
use windmark::{context::RouteContext, response::Response};

fn user(cfg: &Config, ctx: RouteContext, net: &Net) -> Response {
	// matchit does not currently percent-decode paths parameters automatically (see https://github.com/ibraheemdev/matchit/issues/16)
	// so we assume they're already percent-encoded if they are given that way (using PercentEncoded::new_unchecked)
	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))
}

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

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("".into());
	route(handlers::file::handler(
		cfg, net, username, repo, branch, item_path,
	))
}

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

#[windmark::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	let cfg: &Config = config(); // Halts if misconfigured

	/// The shared network manager.
	static NET: LazyLock<Net> = LazyLock::new(|| {
		let cfg: &Config = config();

		// Fetch robots.txt if we can
		let api = cfg.forge_api();
		let base_url = &cfg.forge_url;
		let restrictions = RobotsTxt::fetch_from(api, base_url);
		Net::new(restrictions)
	});
	let net = &*NET;

	// If we have an access token, check that it is valid
	cfg.forge_api().check_access_token(net)?;

	// Make sure we can connect to the forge
	let kind = cfg.forge_api().kind().to_string();
	match cfg.forge_api().get_version(net) {
		Ok(server_version) => {
			// Print the forge's reported 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);
		}
	};

	windmark::router::Router::new()
		.set_private_key_file(format!("{}/key.pem", cfg.certs_dir))
		.set_certificate_file(format!("{}/cert.pem", cfg.certs_dir))
		.add_header(move |_| format!("# {kind} Proxy"))
		.add_footer(|ctx: RouteContext| {
			let base: &str = ctx.url.path().split("/").collect::<Vec<&str>>()[1];
			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}")
		})
		.mount("/", |_| route(handlers::root::handler(cfg, net)))
		.mount("/users", |_| route(handlers::users::handler(cfg, net)))
		.mount("/users/", |_| route(handlers::users::handler(cfg, net)))
		.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)
		})
		// .mount("/*", windmark::not_found!("Nothing to be found here!"))
		.set_error_handler(|error| {
			let failure_addr = error.url;
			println!("↘ Failed to serve {failure_addr}");
			Response::temporary_failure("Something went wrong!")
		})
		.run()
		.await
}

/// Returns an appropriate response for the given handler result.
fn route(res: Result<Response, NetworkError>) -> Response {
	return match res {
		Ok(res) => res,
		Err(err) => err.gemini_response(),
	};
}