git-gemini-forge 0.6.4

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(String::new());
	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);
		}
	};

	println!("Serving capsule on port {}", cfg.port);
	windmark::router::Router::new()
		.set_port(cfg.port.into())
		// .set_fix_path(true) // TODO: Consider this after https://github.com/gemrest/windmark/issues/5 is fixed
		.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("/", |_| route(handlers::root::handler(cfg, net)))
		.mount("/robots.txt", |_| route(handlers::robotstxt::handler(cfg)))
		.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 {
	let out = match res {
		Ok(res) => res,
		Err(err) => err.gemini_response(),
	};

	println!("{}", describe(&out));
	out
}

/// Describes the [Gemini response] status and, if applicable, prints its message.
///
/// [Gemini response]: https://geminiprotocol.net/docs/tech-overview.gmi#3-gemini-responses
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),
	}
}