use std::{sync::Arc, time::Duration}; use anyhow::Error; use poem::{ EndpointExt, Request, Route, endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint}, error::InternalServerError, get, http::header, middleware::{NormalizePath, SetHeader, TrailingSlash}, post, }; use rust_embed::Embed; use crate::{ Config, git::Git, pages, repos::{RepoName, Store}, smart_http, }; /// The stylesheet and favicon, embedded in the binary. #[derive(Embed)] #[folder = "static/"] struct Static; type StaticFiles = EmbeddedFilesEndpoint; type StaticFile = EmbeddedFileEndpoint; /// Everything a request handler needs: the configuration, the repository /// store built from it, and the git processes that serve bytes in bulk. pub struct App { pub config: Config, pub store: Store, pub git: Git, } impl App { pub fn new(config: Config) -> Result, Error> { config.validate()?; let store = Store::new(&config.repos)?; let git = Git::new( store.root().to_owned(), config.git_processes, Duration::from_secs(config.git_idle_timeout), ); Ok(Arc::new(App { config, store, git })) } /// Runs work against the store on the blocking pool, since libgit2 and /// the file system are synchronous. A task that fails to run at all is /// a server error; what the work itself reports is passed through. pub async fn blocking(&self, work: F) -> poem::Result where T: Send + 'static, F: FnOnce(&Store) -> Result + Send + 'static, { let store = self.store.clone(); let result = tokio::task::spawn_blocking(move || work(&store)) .await .map_err(InternalServerError)?; Ok(result?) } /// The clone URL shown for a repository. When the configuration does /// not say where the site is, it is wherever the request reached it. pub fn clone_url(&self, req: &Request, name: &RepoName) -> String { let base = self.config.public_url().unwrap_or_else(|| { let host = req.header(header::HOST).unwrap_or(&self.config.listen); format!("{}://{host}", req.scheme()) }); format!("{base}/{name}.git") } /// The complete route table of the site. pub fn routes(self: &Arc) -> impl poem::Endpoint + 'static { Route::new() .at("/favicon.svg", StaticFile::new("favicon.svg")) .nest("/-static", StaticFiles::new()) .at("/", get(pages::index::handler)) .at("/:repo", get(pages::repo::handler)) .at("/:repo/file", get(pages::file::root)) .at("/:repo/file/*path", get(pages::file::page)) .at("/:repo/raw/*path", get(pages::file::raw)) .at("/:repo/info/refs", get(smart_http::info_refs)) .at("/:repo/git-upload-pack", post(smart_http::upload_pack)) .with(NormalizePath::new(TrailingSlash::Trim)) .data(self.clone()) // Every failure becomes a page of the site. Poem would answer // with a bare status and say nothing; the journal is where a // server-side failure has to be visible. .catch_all_error({ let app = self.clone(); move |err| { let app = app.clone(); async move { // A handler that answered with a complete response // of its own, headers and all, is passed through. if err.is_from_response() { return err.into_response(); } if err.status().is_server_error() { tracing::error!(error = %err, "request failed"); } pages::error(&app, &err) } } }) .with(self.browser_headers()) } // Headers every response carries for the browser's sake: no sniffing // a type the site did not declare, no leaking a page's URL to other // sites in full, and, once the site has a TLS domain, no plain HTTP // for a year. fn browser_headers(&self) -> SetHeader { let headers = SetHeader::new() .overriding(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .overriding(header::REFERRER_POLICY, "strict-origin-when-cross-origin"); if self.config.tls_domain.is_some() { headers.overriding(header::STRICT_TRANSPORT_SECURITY, "max-age=31536000") } else { headers } } }