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