app.rs
raw
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<Static>;
type StaticFile = EmbeddedFileEndpoint<Static>;
/// 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<Arc<Self>, 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<T, F>(&self, work: F) -> poem::Result<T>
where
T: Send + 'static,
F: FnOnce(&Store) -> Result<T, Error> + 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<Self>) -> 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
}
}
}