michal/tit

Browse tree · Show commit · Download archive

Blob: src/http/mod.rs

Raw · Blame

mod feeds;
mod issues;
mod metadata_search;
mod public;
mod pull_requests;
mod repository_settings;
mod watches;

use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};

use askama::Template;
use axum::Router;
use axum::body::{Body, Bytes, HttpBody};
use axum::extract::{
    ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, Path, Query, Request, State,
};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::get;
use thiserror::Error;
use tokio::net::TcpListener;
use tokio::sync::{Semaphore, oneshot};
use tokio::task::JoinHandle;
use tower_http::limit::RequestBodyLimitLayer;

use crate::account::{AccountError, AccountKeyRequest, AccountService};
use crate::auth::validate_username;
use crate::feed_token::FeedTokenService;
use crate::issue::IssueService;
use crate::maintenance::MaintenanceGate;
use crate::pull_request::PullRequestService;
use crate::rate_limit::AttemptLimiter;
use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::search::MetadataSearchService;
use crate::session::{SessionError, WebLoginService};
use crate::store::StoreError;
use crate::telemetry::Telemetry;
use crate::watch::WatchService;

use self::public::PublicWeb;

const STYLE: &str = include_str!("../../assets/style.css");
const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
const MAX_BLOCKING_WEB_JOBS: usize = 8;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const CONCURRENCY_WAIT: Duration = Duration::from_secs(1);
const LOGIN_ATTEMPTS_PER_MINUTE: usize = 10;
const MAX_LOGIN_CLIENTS: usize = 4096;
const MAX_LOGIN_MULTIPART_FIELDS: usize = 4;
const MAX_LOGIN_MULTIPART_FIELD_BYTES: usize = 48 * 1024;
const MAX_LOGIN_MULTIPART_DECODED_BYTES: usize = 60 * 1024;
const SESSION_COOKIE: &str = "tit-session";
const CSRF_COOKIE: &str = "tit-csrf";
const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";

mod filters {
    use askama::{Result, Values};

    #[askama::filter_fn]
    pub fn human_time<T: std::borrow::Borrow<i64>>(timestamp: T, _: &dyn Values) -> Result<String> {
        Ok(
            jiff::Timestamp::from_second(*timestamp.borrow()).map_or_else(
                |_| "Invalid time".to_owned(),
                |timestamp| timestamp.strftime("%Y-%m-%d %H:%M UTC").to_string(),
            ),
        )
    }

    #[askama::filter_fn]
    pub fn short_id<T: AsRef<str>>(id: T, _: &dyn Values) -> Result<String> {
        Ok(id.as_ref().chars().take(12).collect())
    }

    #[askama::filter_fn]
    pub fn event_name<T: AsRef<str>>(kind: T, _: &dyn Values) -> Result<String> {
        let name = match kind.as_ref() {
            "issue-created" => "created the issue",
            "issue-edited" => "edited the issue",
            "issue-commented" => "added a comment",
            "issue-opened" | "issue-reopened" => "reopened the issue",
            "issue-closed" => "closed the issue",
            "pull-request-opened" => "opened the pull request",
            "pull-request-revised" => "recorded a new revision",
            "pull-request-edited" => "edited the pull request",
            "pull-request-closed" => "closed the pull request",
            "pull-request-reopened" => "reopened the pull request",
            "pull-request-commented" => "added a review comment",
            "pull-request-approved" => "approved the pull request",
            "pull-request-changes-requested" => "requested changes",
            "pull-request-merged" => "merged the pull request",
            other => return Ok(other.replace('-', " ")),
        };
        Ok(name.to_owned())
    }
}

fn format_time(timestamp: i64) -> String {
    jiff::Timestamp::from_second(timestamp).map_or_else(
        |_| "Invalid time".to_owned(),
        |timestamp| timestamp.strftime("%Y-%m-%d %H:%M UTC").to_string(),
    )
}

#[derive(Clone)]
struct WebState {
    public: Option<PublicWeb>,
    accounts: Option<AccountService>,
    jobs: Arc<Semaphore>,
    requests: Arc<Semaphore>,
    login_attempts: AttemptLimiter<IpAddr>,
    account_attempts: AttemptLimiter<IpAddr>,
    max_request_bytes: usize,
    telemetry: Telemetry,
    key_reloader: Option<AccountKeyReloader>,
    login: Option<WebLoginService>,
    ssh_login_target: Option<SshLoginTarget>,
    repositories: Option<RepositoryService>,
    issues: Option<IssueService>,
    pull_requests: Option<PullRequestService>,
    feeds: Option<FeedTokenService>,
    search: Option<MetadataSearchService>,
    watches: Option<WatchService>,
    readiness: Option<ListenerReadiness>,
    secure_cookies: bool,
}

#[derive(Clone)]
struct SshLoginTarget {
    host: String,
    port: u16,
}

impl SshLoginTarget {
    fn command(&self, secret: &str) -> String {
        format!(
            "ssh -p {} {} auth {}",
            self.port,
            shell_word(&self.host),
            secret
        )
    }
}

#[derive(Clone)]
pub(super) struct RequestActor(pub(super) Option<String>);

#[derive(Clone, Copy)]
struct ClientAddress(IpAddr);

type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;

#[derive(Clone, Debug)]
pub(crate) struct PublicWebConfig {
    pub(crate) instance_dir: PathBuf,
    pub(crate) http_clone_base: String,
    pub(crate) ssh_clone_base: String,
    pub(crate) max_request_bytes: usize,
    pub(crate) max_connections: usize,
}

#[derive(Clone, Default)]
pub(crate) struct ListenerReadiness {
    ready: Arc<AtomicBool>,
}

impl ListenerReadiness {
    pub(crate) fn mark_ready(&self) {
        self.ready.store(true, Ordering::Release);
    }

    pub(crate) fn mark_stopping(&self) {
        self.ready.store(false, Ordering::Release);
    }

    fn is_ready(&self) -> bool {
        self.ready.load(Ordering::Acquire)
    }
}

pub(crate) struct RunningWebServer {
    address: SocketAddr,
    shutdown: oneshot::Sender<()>,
    task: JoinHandle<std::io::Result<()>>,
}

impl RunningWebServer {
    pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
        Self::start_with_state(
            address,
            WebState {
                public: None,
                accounts: None,
                jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
                requests: Arc::new(Semaphore::new(1024)),
                login_attempts: login_attempt_limiter(),
                account_attempts: login_attempt_limiter(),
                max_request_bytes: 1024 * 1024,
                telemetry: Telemetry::default(),
                key_reloader: None,
                login: None,
                ssh_login_target: None,
                repositories: None,
                issues: None,
                pull_requests: None,
                feeds: None,
                search: None,
                watches: None,
                readiness: None,
                secure_cookies: false,
            },
        )
        .await
    }

    pub(crate) async fn start_public(
        address: SocketAddr,
        config: PublicWebConfig,
    ) -> Result<Self, WebError> {
        Self::start_public_inner(address, config, None, None, None, Telemetry::default()).await
    }

    pub(crate) async fn start_public_with_key_reload(
        address: SocketAddr,
        config: PublicWebConfig,
        key_reloader: AccountKeyReloader,
        readiness: ListenerReadiness,
        maintenance: MaintenanceGate,
        telemetry: Telemetry,
    ) -> Result<Self, WebError> {
        Self::start_public_inner(
            address,
            config,
            Some(key_reloader),
            Some(readiness),
            Some(maintenance),
            telemetry,
        )
        .await
    }

    async fn start_public_inner(
        address: SocketAddr,
        config: PublicWebConfig,
        key_reloader: Option<AccountKeyReloader>,
        readiness: Option<ListenerReadiness>,
        maintenance: Option<MaintenanceGate>,
        telemetry: Telemetry,
    ) -> Result<Self, WebError> {
        let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
        let requests = Arc::new(Semaphore::new(config.max_connections));
        let max_request_bytes = config.max_request_bytes;
        let ssh_login_target = parse_ssh_login_target(&config.ssh_clone_base)?;
        let database = config.instance_dir.join(crate::store::DATABASE_FILE);
        let accounts = AccountService::new(database.clone());
        let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
            .map_err(WebError::CanonicalUrl)?;
        let secure_cookies = public_url.scheme() == "https";
        let login = WebLoginService::new(database, &public_url)?;
        let public = PublicWeb::open(config, Arc::clone(&jobs))?;
        let repositories = match maintenance.clone() {
            Some(gate) => {
                RepositoryService::new_with_gate(public.database(), public.repository_root(), gate)
            }
            None => RepositoryService::new(public.database(), public.repository_root()),
        };
        let issues = IssueService::new(public.database());
        let pull_requests = match maintenance {
            Some(gate) => {
                PullRequestService::new_with_gate(public.database(), public.repository_root(), gate)
            }
            None => PullRequestService::new(public.database(), public.repository_root()),
        };
        let feeds = FeedTokenService::new(public.database());
        let search = MetadataSearchService::new(public.database());
        let watches = WatchService::new(public.database());
        Self::start_with_state(
            address,
            WebState {
                public: Some(public),
                accounts: Some(accounts),
                jobs,
                requests,
                login_attempts: login_attempt_limiter(),
                account_attempts: login_attempt_limiter(),
                max_request_bytes,
                telemetry,
                key_reloader,
                login: Some(login),
                ssh_login_target: Some(ssh_login_target),
                repositories: Some(repositories),
                issues: Some(issues),
                pull_requests: Some(pull_requests),
                feeds: Some(feeds),
                search: Some(search),
                watches: Some(watches),
                readiness,
                secure_cookies,
            },
        )
        .await
    }

    async fn start_with_state(address: SocketAddr, state: WebState) -> Result<Self, WebError> {
        let listener = TcpListener::bind(address).await?;
        let address = listener.local_addr()?;
        let (shutdown, receiver) = oneshot::channel();
        let task = tokio::spawn(async move {
            axum::serve(
                listener,
                router_with_state(state).into_make_service_with_connect_info::<SocketAddr>(),
            )
            .with_graceful_shutdown(async {
                let _ = receiver.await;
            })
            .await
        });
        Ok(Self {
            address,
            shutdown,
            task,
        })
    }

    pub(crate) fn address(&self) -> SocketAddr {
        self.address
    }

    pub(crate) async fn shutdown(self) -> Result<(), WebError> {
        let _ = self.shutdown.send(());
        self.task.await.map_err(|_| WebError::Join)??;
        Ok(())
    }

    pub(crate) async fn shutdown_bounded(mut self, limit: Duration) -> Result<bool, WebError> {
        let _ = self.shutdown.send(());
        match tokio::time::timeout(limit, &mut self.task).await {
            Ok(result) => {
                result.map_err(|_| WebError::Join)??;
                Ok(true)
            }
            Err(_) => {
                self.task.abort();
                let _ = self.task.await;
                Ok(false)
            }
        }
    }
}

pub(crate) fn router() -> Router {
    router_with_state(WebState {
        public: None,
        accounts: None,
        jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
        requests: Arc::new(Semaphore::new(1024)),
        login_attempts: login_attempt_limiter(),
        account_attempts: login_attempt_limiter(),
        max_request_bytes: 1024 * 1024,
        telemetry: Telemetry::default(),
        key_reloader: None,
        login: None,
        ssh_login_target: None,
        repositories: None,
        issues: None,
        pull_requests: None,
        feeds: None,
        search: None,
        watches: None,
        readiness: None,
        secure_cookies: false,
    })
}

fn router_with_state(state: WebState) -> Router {
    let max_request_bytes = state.max_request_bytes;
    let repository_routes = metadata_search::routes()
        .merge(watches::routes())
        .merge(issues::routes())
        .merge(pull_requests::routes())
        .merge(repository_settings::routes())
        .merge(public::routes());
    Router::new()
        .route("/", get(home))
        .route("/healthz", get(health))
        .route("/metrics", get(metrics))
        .route(
            "/signup",
            get(signup_form)
                .post(signup)
                .layer(DefaultBodyLimit::max(20 * 1024)),
        )
        .route(
            "/recover",
            get(recovery_form)
                .post(recover)
                .layer(DefaultBodyLimit::max(20 * 1024)),
        )
        .route(
            "/login",
            get(login_form)
                .post(login_challenge)
                .layer(DefaultBodyLimit::max(32 * 1024)),
        )
        .route(
            "/login/ssh",
            axum::routing::post(login_ssh).layer(DefaultBodyLimit::max(1024)),
        )
        .route(
            "/login/ssh/complete",
            axum::routing::post(login_ssh_complete).layer(DefaultBodyLimit::max(1024)),
        )
        .route(
            "/login/verify",
            axum::routing::post(login_verify).layer(DefaultBodyLimit::max(64 * 1024)),
        )
        .route(
            "/login/verify-file",
            axum::routing::post(login_verify_file).layer(DefaultBodyLimit::max(64 * 1024)),
        )
        .route(
            "/login/challenge.txt",
            axum::routing::post(login_challenge_download).layer(DefaultBodyLimit::max(16 * 1024)),
        )
        .route("/account", get(account_page))
        .route(
            "/account/profile",
            axum::routing::post(update_profile).layer(DefaultBodyLimit::max(2048)),
        )
        .route(
            "/account/repositories",
            axum::routing::post(create_repository).layer(DefaultBodyLimit::max(4 * 1024)),
        )
        .route(
            "/account/keys/add",
            axum::routing::post(begin_key_add).layer(DefaultBodyLimit::max(32 * 1024)),
        )
        .route(
            "/account/keys/add/complete",
            axum::routing::post(complete_key_add).layer(DefaultBodyLimit::max(32 * 1024)),
        )
        .route(
            "/account/keys/revoke",
            axum::routing::post(begin_key_revoke).layer(DefaultBodyLimit::max(4 * 1024)),
        )
        .route(
            "/account/keys/revoke/complete",
            axum::routing::post(complete_key_revoke).layer(DefaultBodyLimit::max(4 * 1024)),
        )
        .route(
            "/logout",
            get(logout_form)
                .post(logout)
                .layer(DefaultBodyLimit::max(1024)),
        )
        .route("/assets/style.css", get(style))
        .merge(feeds::routes())
        .merge(repository_routes)
        .route("/{username}", get(public_profile))
        .fallback(not_found)
        .method_not_allowed_fallback(method_not_allowed)
        .layer(RequestBodyLimitLayer::new(max_request_bytes))
        .layer(middleware::from_fn_with_state(state.clone(), request_actor))
        .layer(middleware::from_fn_with_state(state.clone(), request_guard))
        .layer(middleware::from_fn_with_state(
            state.clone(),
            response_policy,
        ))
        .with_state(state)
}

async fn metrics(State(state): State<WebState>) -> Response {
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::from(state.telemetry.metrics()))
        .expect("the metrics response is valid")
}

fn login_attempt_limiter() -> AttemptLimiter<IpAddr> {
    AttemptLimiter::new(
        LOGIN_ATTEMPTS_PER_MINUTE,
        Duration::from_secs(60),
        MAX_LOGIN_CLIENTS,
    )
}

fn parse_ssh_login_target(value: &str) -> Result<SshLoginTarget, WebError> {
    let url = url::Url::parse(value).map_err(WebError::CanonicalUrl)?;
    let host = url.host_str().ok_or_else(|| {
        WebError::PublicConfig("the SSH clone URL does not have a host".to_owned())
    })?;
    Ok(SshLoginTarget {
        host: if host.contains(':') {
            format!("[{host}]")
        } else {
            host.to_owned()
        },
        port: url.port().unwrap_or(22),
    })
}

fn shell_word(value: &str) -> String {
    if value.bytes().all(|byte| {
        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'[' | b']' | b':')
    }) {
        value.to_owned()
    } else {
        format!("'{}'", value.replace('\'', "'\\''"))
    }
}

async fn request_guard(
    State(state): State<WebState>,
    mut request: Request,
    next: Next,
) -> Response {
    let address = request
        .extensions()
        .get::<ConnectInfo<SocketAddr>>()
        .map(|peer| peer.0.ip())
        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
    request.extensions_mut().insert(ClientAddress(address));
    let permit = match tokio::time::timeout(
        CONCURRENCY_WAIT,
        state.requests.clone().acquire_owned(),
    )
    .await
    {
        Ok(Ok(permit)) => permit,
        _ => return limit_response(StatusCode::SERVICE_UNAVAILABLE, "Server is busy.\n"),
    };
    let response = tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await;
    drop(permit);
    match response {
        Ok(response) => response,
        Err(_) => limit_response(
            StatusCode::REQUEST_TIMEOUT,
            "Request time limit exceeded.\n",
        ),
    }
}

fn limit_response(status: StatusCode, message: &'static str) -> Response {
    Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::from(message))
        .expect("the limit response is valid")
}

fn allow_login_attempt(state: &WebState, peer: ClientAddress) -> bool {
    state.login_attempts.allow(peer.0)
}

fn allow_account_attempt(state: &WebState, peer: ClientAddress) -> bool {
    state.account_attempts.allow(peer.0)
}

async fn request_actor(
    State(state): State<WebState>,
    mut request: Request,
    next: Next,
) -> Response {
    let actor = match cookie(request.headers(), SESSION_COOKIE) {
        Some(session) => login_job(state, move |login| login.authenticate(&session, None))
            .await
            .ok()
            .map(|record| record.username),
        None => None,
    };
    request.extensions_mut().insert(RequestActor(actor));
    next.run(request).await
}

async fn home(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    headers: HeaderMap,
) -> Response {
    let signed_in = actor.0.is_some();
    let username = actor.0.clone().unwrap_or_default();
    let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
    if state.repositories.is_none() {
        return render_home(
            StatusCode::OK,
            &request_id.0,
            HomePage {
                signed_in,
                username: &username,
                csrf: &csrf,
                repositories: &[],
                recent_repositories: &[],
            },
        );
    }
    let result = repository_job(state, move |repositories| {
        let owned = match actor.0.as_deref() {
            Some(owner) => repositories.home(Some(owner))?,
            None => Vec::new(),
        };
        let recent = repositories.home(None)?;
        Ok((owned, recent))
    })
    .await;
    match result {
        Ok((repositories, recent_repositories)) => render_home(
            StatusCode::OK,
            &request_id.0,
            HomePage {
                signed_in,
                username: &username,
                csrf: &csrf,
                repositories: &repositories,
                recent_repositories: &recent_repositories,
            },
        ),
        Err(_) => render_error(
            StatusCode::INTERNAL_SERVER_ERROR,
            &request_id.0,
            "Home error",
            "The repository overview could not be completed.",
        ),
    }
}

async fn health(State(state): State<WebState>) -> Response {
    let ready = state
        .readiness
        .as_ref()
        .is_none_or(ListenerReadiness::is_ready);
    let (status, body) = if ready {
        (StatusCode::OK, "ready\n")
    } else {
        (StatusCode::SERVICE_UNAVAILABLE, "not ready\n")
    };
    Response::builder()
        .status(status)
        .header(header::CACHE_CONTROL, "no-store")
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .body(Body::from(body))
        .expect("the readiness response is valid")
}

async fn signup_form(
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
) -> Response {
    if actor.0.is_some() {
        return account_redirect();
    }
    render_account_form(&request_id.0, AccountFormKind::Signup, "", "")
}

async fn recovery_form(
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
) -> Response {
    if actor.0.is_some() {
        return account_redirect();
    }
    render_account_form(&request_id.0, AccountFormKind::Recovery, "", "")
}

async fn signup(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    if !allow_account_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Account attempt limit exceeded.\n",
        );
    }
    let fields = match parse_account_form(&headers, &body, "invitation") {
        Ok(fields) => fields,
        Err(()) => {
            return render_account_error(
                &request_id.0,
                AccountFormKind::Signup,
                "",
                "The signup request is not valid.",
                StatusCode::BAD_REQUEST,
            );
        }
    };
    let username = fields.username.clone();
    let correlation_id = request_id.0.clone();
    let result = account_job(state, move |accounts| {
        accounts.signup(
            &fields.credential,
            &fields.username,
            &fields.public_key,
            &correlation_id,
        )
    })
    .await;
    account_result(result, &request_id.0, AccountFormKind::Signup, &username)
}

async fn recover(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    if !allow_account_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Account attempt limit exceeded.\n",
        );
    }
    let fields = match parse_account_form(&headers, &body, "recovery") {
        Ok(fields) => fields,
        Err(()) => {
            return render_account_error(
                &request_id.0,
                AccountFormKind::Recovery,
                "",
                "The recovery request is not valid.",
                StatusCode::BAD_REQUEST,
            );
        }
    };
    let username = fields.username.clone();
    let correlation_id = request_id.0.clone();
    let result = account_job(state, move |accounts| {
        accounts.recover(
            &fields.username,
            &fields.credential,
            &fields.public_key,
            &correlation_id,
        )
    })
    .await;
    account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
}

async fn login_form(
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
) -> Response {
    if actor.0.is_some() {
        return account_redirect();
    }
    render(
        StatusCode::OK,
        &LoginTemplate {
            request_id: &request_id.0,
            username: "",
            error: "",
            has_error: false,
            signed_in: false,
        },
    )
}

async fn login_challenge(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    if !allow_login_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Login attempt limit exceeded.\n",
        );
    }
    let fields = match parse_named_form(&headers, &body, &["username"]) {
        Ok(fields) => fields,
        Err(()) => return login_error(&request_id.0, "", "The login request is not valid."),
    };
    let username = fields[0].clone();
    let display_username = username.clone();
    let secure = state.secure_cookies;
    let result = login_job(state, move |login| login.issue(&username)).await;
    match result {
        Ok(challenge) => {
            let mut response = render(
                StatusCode::OK,
                &LoginChallengeTemplate {
                    request_id: &request_id.0,
                    username: &display_username,
                    challenge: &challenge.challenge,
                    login_csrf: &challenge.login_csrf,
                    error: "",
                    has_error: false,
                    signed_in: false,
                },
            );
            append_cookie(
                response.headers_mut(),
                LOGIN_CSRF_COOKIE,
                &challenge.login_csrf,
                true,
                secure,
                5 * 60,
            );
            response
        }
        Err(_) => login_error(
            &request_id.0,
            &display_username,
            "The username is not valid or the account is not active.",
        ),
    }
}

async fn login_ssh(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
) -> Response {
    if !allow_login_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Login attempt limit exceeded.\n",
        );
    }
    let Some(target) = state.ssh_login_target.clone() else {
        return login_error(
            &request_id.0,
            "",
            "SSH login is not available on this instance.",
        );
    };
    let secure = state.secure_cookies;
    match login_job(state, |login| login.issue_approval()).await {
        Ok(approval) => {
            let command = target.command(&approval.secret);
            let mut response = render(
                StatusCode::OK,
                &LoginSshTemplate {
                    request_id: &request_id.0,
                    command: &command,
                    secret: &approval.secret,
                    login_csrf: &approval.login_csrf,
                    error: "",
                    has_error: false,
                    signed_in: false,
                },
            );
            append_cookie(
                response.headers_mut(),
                LOGIN_CSRF_COOKIE,
                &approval.login_csrf,
                true,
                secure,
                5 * 60,
            );
            response
        }
        Err(_) => login_error(
            &request_id.0,
            "",
            "The SSH login request could not be created.",
        ),
    }
}

async fn login_ssh_complete(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    if !allow_login_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Login attempt limit exceeded.\n",
        );
    }
    let fields = match parse_named_form(&headers, &body, &["secret", "login-csrf"]) {
        Ok(fields) => fields,
        Err(()) => {
            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
                .await;
        }
    };
    let secret = fields[0].clone();
    let login_csrf = fields[1].clone();
    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
    }
    let secure = state.secure_cookies;
    let correlation_id = request_id.0.clone();
    let result = login_job(state.clone(), {
        let secret = secret.clone();
        let login_csrf = login_csrf.clone();
        move |login| login.complete_approval(&secret, &login_csrf, &correlation_id)
    })
    .await;
    match result {
        Ok(session) => login_success(session, secure),
        Err(SessionError::Store(StoreError::LoginApprovalPending)) => {
            let Some(target) = state.ssh_login_target else {
                return login_error(
                    &request_id.0,
                    "",
                    "SSH login is not available on this instance.",
                );
            };
            let command = target.command(&secret);
            render(
                StatusCode::CONFLICT,
                &LoginSshTemplate {
                    request_id: &request_id.0,
                    command: &command,
                    secret: &secret,
                    login_csrf: &login_csrf,
                    error: "Authenticate with SSH before you continue.",
                    has_error: true,
                    signed_in: false,
                },
            )
        }
        Err(_) => login_error(
            &request_id.0,
            "",
            "The SSH login request is invalid or has expired.",
        ),
    }
}

async fn login_verify(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    if !allow_login_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Login attempt limit exceeded.\n",
        );
    }
    let fields = match parse_named_form(
        &headers,
        &body,
        &["username", "challenge", "signature", "login-csrf"],
    ) {
        Ok(fields) => fields,
        Err(()) => {
            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
                .await;
        }
    };
    let username = fields[0].clone();
    let challenge = fields[1].clone();
    let signature = fields[2].clone();
    let login_csrf = fields[3].clone();
    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
        return rejected_login(
            state,
            &request_id.0,
            &username,
            "The login response is not valid.",
        )
        .await;
    }
    complete_login(
        state,
        &request_id.0,
        username,
        challenge,
        signature,
        login_csrf,
    )
    .await
}

async fn login_challenge_download(headers: HeaderMap, body: Bytes) -> Response {
    let fields = match parse_named_form(&headers, &body, &["username", "challenge", "login-csrf"]) {
        Ok(fields) => fields,
        Err(()) => return limit_response(StatusCode::BAD_REQUEST, "The request is not valid.\n"),
    };
    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(fields[2].as_str()) {
        return limit_response(StatusCode::FORBIDDEN, "The request is not authorized.\n");
    }
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .header(
            header::CONTENT_DISPOSITION,
            "attachment; filename=\"tit-login-challenge.txt\"",
        )
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::from(normalize_browser_newlines(fields[1].clone())))
        .expect("the login challenge download is valid")
}

async fn login_verify_file(
    State(state): State<WebState>,
    Extension(peer): Extension<ClientAddress>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Body,
) -> Response {
    if !allow_login_attempt(&state, peer) {
        return limit_response(
            StatusCode::TOO_MANY_REQUESTS,
            "Login attempt limit exceeded.\n",
        );
    }
    let Some(content_type) = headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
    else {
        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
    };
    let Ok(boundary) = multra::parse_boundary(content_type) else {
        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
    };
    let mut multipart = multra::Multipart::new(body.into_data_stream(), boundary);
    let mut username = None;
    let mut challenge = None;
    let mut signature = None;
    let mut login_csrf = None;
    let mut field_count = 0_usize;
    let mut decoded_bytes = 0_usize;
    loop {
        let mut field = match multipart.next_field().await {
            Ok(Some(field)) => field,
            Ok(None) => break,
            Err(_) => {
                return rejected_login(
                    state,
                    &request_id.0,
                    "",
                    "The login response is not valid.",
                )
                .await;
            }
        };
        field_count += 1;
        if field_count > MAX_LOGIN_MULTIPART_FIELDS {
            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
                .await;
        }
        let value = match read_login_field(&mut field, &mut decoded_bytes).await {
            Ok(value) => value,
            Err(()) => {
                return rejected_login(
                    state,
                    &request_id.0,
                    "",
                    "The login response is not valid.",
                )
                .await;
            }
        };
        match field.name() {
            Some("username") if username.is_none() => username = Some(value),
            Some("challenge") if challenge.is_none() => challenge = Some(value),
            Some("signature-file") if signature.is_none() => signature = Some(value),
            Some("login-csrf") if login_csrf.is_none() => login_csrf = Some(value),
            _ => {
                return rejected_login(
                    state,
                    &request_id.0,
                    "",
                    "The login response is not valid.",
                )
                .await;
            }
        }
    }
    let (Some(username), Some(challenge), Some(signature), Some(login_csrf)) =
        (username, challenge, signature, login_csrf)
    else {
        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
    };
    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
        return rejected_login(
            state,
            &request_id.0,
            &username,
            "The login response is not valid.",
        )
        .await;
    }
    complete_login(
        state,
        &request_id.0,
        username,
        challenge,
        signature,
        login_csrf,
    )
    .await
}

async fn read_login_field(
    field: &mut multra::Field<'_>,
    decoded_bytes: &mut usize,
) -> Result<String, ()> {
    let mut bytes = Vec::new();
    while let Some(chunk) = field.chunk().await.map_err(|_| ())? {
        if bytes
            .len()
            .checked_add(chunk.len())
            .is_none_or(|size| size > MAX_LOGIN_MULTIPART_FIELD_BYTES)
        {
            return Err(());
        }
        *decoded_bytes = decoded_bytes.checked_add(chunk.len()).ok_or(())?;
        if *decoded_bytes > MAX_LOGIN_MULTIPART_DECODED_BYTES {
            return Err(());
        }
        bytes.extend_from_slice(&chunk);
    }
    String::from_utf8(bytes).map_err(|_| ())
}

async fn complete_login(
    state: WebState,
    request_id: &str,
    username: String,
    challenge: String,
    signature: String,
    login_csrf: String,
) -> Response {
    let display_username = username.clone();
    let challenge = normalize_browser_newlines(challenge);
    let display_challenge = challenge.clone();
    let display_login_csrf = login_csrf.clone();
    let secure = state.secure_cookies;
    let correlation_id = request_id.to_owned();
    let result = login_job(state, move |login| {
        login.verify(
            &username,
            &challenge,
            &signature,
            &login_csrf,
            &correlation_id,
        )
    })
    .await;
    match result {
        Ok(session) => login_success(session, secure),
        Err(_) => render(
            StatusCode::BAD_REQUEST,
            &LoginChallengeTemplate {
                request_id,
                username: &display_username,
                challenge: &display_challenge,
                login_csrf: &display_login_csrf,
                error: "The signature is not valid or the challenge has expired.",
                has_error: true,
                signed_in: false,
            },
        ),
    }
}

fn login_success(session: crate::session::NewSession, secure: bool) -> Response {
    let mut response = Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, "/account")
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::empty())
        .expect("the login redirect is valid");
    append_cookie(
        response.headers_mut(),
        SESSION_COOKIE,
        &session.token,
        true,
        secure,
        SESSION_COOKIE_MAX_AGE,
    );
    append_cookie(
        response.headers_mut(),
        LOGIN_CSRF_COOKIE,
        "",
        true,
        secure,
        0,
    );
    append_cookie(
        response.headers_mut(),
        CSRF_COOKIE,
        &session.csrf,
        false,
        secure,
        SESSION_COOKIE_MAX_AGE,
    );
    response
}

fn normalize_browser_newlines(value: String) -> String {
    if value.contains("\r\n") {
        value.replace("\r\n", "\n")
    } else {
        value
    }
}

const SESSION_COOKIE_MAX_AGE: i64 = 7 * 24 * 60 * 60;

async fn account_page(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
) -> Response {
    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(false);
    };
    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
        return login_redirect(true);
    };
    let csrf_for_auth = csrf.clone();
    match login_job(state.clone(), move |login| {
        login.authenticate(&session_token, Some(&csrf_for_auth))
    })
    .await
    {
        Ok(session) => {
            let username = session.username.clone();
            let details = account_job(state, move |accounts| {
                let profile = accounts.profile(&username)?;
                let keys = accounts.keys(&username)?;
                Ok((profile, keys))
            })
            .await;
            match details {
                Ok((profile, keys)) => render(
                    StatusCode::OK,
                    &AccountTemplate {
                        request_id: &request_id.0,
                        username: &session.username,
                        administrator: session.is_administrator,
                        csrf: &csrf,
                        bio: &profile.bio,
                        contact_email: &profile.contact_email,
                        keys: keys
                            .iter()
                            .map(|key| AccountKeyView {
                                label: &key.label,
                                fingerprint: &key.fingerprint,
                                created_at: format_time(key.created_at),
                                last_used_at: key
                                    .last_used_at
                                    .map(format_time)
                                    .unwrap_or_else(|| "Never".to_owned()),
                                active: key.revoked_at.is_none(),
                            })
                            .collect(),
                        active_key_count: keys
                            .iter()
                            .filter(|key| key.revoked_at.is_none())
                            .count(),
                        signed_in: true,
                    },
                ),
                Err(_) => render_error_with_auth(
                    StatusCode::INTERNAL_SERVER_ERROR,
                    &request_id.0,
                    "Account error",
                    "The account profile could not be read.",
                    true,
                ),
            }
        }
        Err(_) => login_redirect(true),
    }
}

async fn begin_key_add(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "label", "public-key"]) {
        Ok(fields) => fields,
        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
    };
    let username =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(username) => username,
            Err(response) => return response,
        };
    let Some(target) = state.ssh_login_target.clone() else {
        return key_error(&request_id.0, "SSH authentication is not available.");
    };
    let csrf = fields[0].clone();
    let approval = login_job(state, {
        let username = username.clone();
        let csrf = csrf.clone();
        move |login| login.issue_account_approval(&username, &csrf)
    })
    .await;
    match approval {
        Ok(approval) => {
            let command = target.command(&approval.secret);
            render(
                StatusCode::OK,
                &AccountKeyAuthTemplate {
                    request_id: &request_id.0,
                    heading: "Add SSH key",
                    action: "/account/keys/add/complete",
                    command: &command,
                    secret: &approval.secret,
                    csrf: &csrf,
                    label: &fields[1],
                    public_key: &fields[2],
                    fingerprint: "",
                    adding: true,
                    signed_in: true,
                },
            )
        }
        Err(_) => key_error(
            &request_id.0,
            "The authentication request could not be created.",
        ),
    }
}

async fn complete_key_add(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "secret", "label", "public-key"])
    {
        Ok(fields) => fields,
        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
    };
    let username =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(username) => username,
            Err(response) => return response,
        };
    let Some(session) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(false);
    };
    let correlation_id = request_id.0.clone();
    let result = account_job(state, move |accounts| {
        accounts.complete_key_add(
            &AccountKeyRequest {
                username: &username,
                session: &session,
                csrf: &fields[0],
                secret: &fields[1],
                correlation_id: &correlation_id,
            },
            &fields[2],
            &fields[3],
        )
    })
    .await;
    key_change_result(result.map(|_| ()), &request_id.0)
}

async fn begin_key_revoke(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "fingerprint"]) {
        Ok(fields) => fields,
        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
    };
    let username =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(username) => username,
            Err(response) => return response,
        };
    let Some(target) = state.ssh_login_target.clone() else {
        return key_error(&request_id.0, "SSH authentication is not available.");
    };
    let csrf = fields[0].clone();
    let approval = login_job(state, {
        let username = username.clone();
        let csrf = csrf.clone();
        move |login| login.issue_account_approval(&username, &csrf)
    })
    .await;
    match approval {
        Ok(approval) => {
            let command = target.command(&approval.secret);
            render(
                StatusCode::OK,
                &AccountKeyAuthTemplate {
                    request_id: &request_id.0,
                    heading: "Revoke SSH key",
                    action: "/account/keys/revoke/complete",
                    command: &command,
                    secret: &approval.secret,
                    csrf: &csrf,
                    label: "",
                    public_key: "",
                    fingerprint: &fields[1],
                    adding: false,
                    signed_in: true,
                },
            )
        }
        Err(_) => key_error(
            &request_id.0,
            "The authentication request could not be created.",
        ),
    }
}

async fn complete_key_revoke(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "secret", "fingerprint"]) {
        Ok(fields) => fields,
        Err(()) => return key_error(&request_id.0, "The key request is not valid."),
    };
    let username =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(username) => username,
            Err(response) => return response,
        };
    let Some(session) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(false);
    };
    let correlation_id = request_id.0.clone();
    let result = account_job(state, move |accounts| {
        accounts.complete_key_revoke(
            &AccountKeyRequest {
                username: &username,
                session: &session,
                csrf: &fields[0],
                secret: &fields[1],
                correlation_id: &correlation_id,
            },
            &fields[2],
        )
    })
    .await;
    key_change_result(result, &request_id.0)
}

fn key_change_result(result: Result<(), AccountError>, request_id: &str) -> Response {
    match result {
        Ok(()) => account_redirect(),
        Err(AccountError::Store(StoreError::LastKey)) => key_error(
            request_id,
            "An account must keep at least one active SSH key.",
        ),
        Err(AccountError::Store(StoreError::KeyExists)) => {
            key_error(request_id, "The key or active key label already exists.")
        }
        Err(
            AccountError::Auth(_)
            | AccountError::InvalidLabel
            | AccountError::InvalidSecret
            | AccountError::Store(StoreError::InvalidLoginApproval)
            | AccountError::Store(StoreError::KeyNotFound),
        ) => key_error(
            request_id,
            "The key request is invalid, expired, or already used.",
        ),
        Err(_) => key_error(request_id, "The key request could not be completed."),
    }
}

fn key_error(request_id: &str, message: &str) -> Response {
    render_error_with_auth(
        StatusCode::BAD_REQUEST,
        request_id,
        "SSH key error",
        message,
        true,
    )
}

async fn public_profile(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    Path(username): Path<String>,
    Query(query): Query<ProfileQuery>,
) -> Response {
    let signed_in = actor.0.is_some();
    if validate_username(&username).is_err() || state.accounts.is_none() {
        return render_error_with_auth(
            StatusCode::NOT_FOUND,
            &request_id.0,
            "Page not found",
            "The requested page does not exist.",
            signed_in,
        );
    }
    let page_number = query.page.unwrap_or(1);
    let result = account_job(state, move |accounts| {
        accounts.profile_page(&username, page_number)
    })
    .await;
    match result {
        Ok(profile) => render(
            StatusCode::OK,
            &PublicProfileTemplate {
                request_id: &request_id.0,
                signed_in,
                username: &profile.username,
                bio: &profile.bio,
                contact_email: &profile.contact_email,
                repositories: profile
                    .repositories
                    .iter()
                    .map(|repository| HomeRepositoryView {
                        owner: &repository.owner,
                        slug: &repository.slug,
                        visibility: &repository.visibility,
                        state: &repository.state,
                        description: &repository.description,
                        updated_at: repository.updated_at,
                    })
                    .collect(),
                has_previous: profile.page > 1,
                has_next: profile.has_next,
                previous_page: profile.page.saturating_sub(1),
                next_page: profile.page.saturating_add(1),
            },
        ),
        Err(AccountError::Auth(_) | AccountError::Store(StoreError::AccountNotFound(_))) => {
            render_error_with_auth(
                StatusCode::NOT_FOUND,
                &request_id.0,
                "Not found",
                "The profile was not found.",
                signed_in,
            )
        }
        Err(_) => render_error_with_auth(
            StatusCode::INTERNAL_SERVER_ERROR,
            &request_id.0,
            "Profile error",
            "The profile could not be read.",
            signed_in,
        ),
    }
}

async fn update_profile(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "bio", "contact-email"]) {
        Ok(fields) => fields,
        Err(()) => {
            return render_error_with_auth(
                StatusCode::BAD_REQUEST,
                &request_id.0,
                "Profile error",
                "The profile request is not valid.",
                true,
            );
        }
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let bio = fields[1].clone();
    let email = fields[2].clone();
    match account_job(state, move |accounts| {
        accounts.update_profile(&actor, &bio, &email)
    })
    .await
    {
        Ok(()) => account_redirect(),
        Err(AccountError::InvalidProfile) => render_error_with_auth(
            StatusCode::BAD_REQUEST,
            &request_id.0,
            "Profile error",
            "The bio or contact email is not valid.",
            true,
        ),
        Err(_) => render_error_with_auth(
            StatusCode::INTERNAL_SERVER_ERROR,
            &request_id.0,
            "Profile error",
            "The profile could not be saved.",
            true,
        ),
    }
}

async fn create_repository(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "name"]) {
        Ok(fields) => fields,
        Err(()) => {
            return repository_form_error(
                &request_id.0,
                StatusCode::BAD_REQUEST,
                "The repository request is not valid.",
            );
        }
    };
    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(false);
    };
    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
        return login_redirect(true);
    };
    if fields[0] != csrf {
        return repository_form_error(
            &request_id.0,
            StatusCode::FORBIDDEN,
            "The request is not authorized.",
        );
    }
    let csrf_for_auth = csrf.clone();
    let session = match login_job(state.clone(), move |login| {
        login.authenticate(&session_token, Some(&csrf_for_auth))
    })
    .await
    {
        Ok(session) => session,
        Err(_) => return login_redirect(true),
    };
    let owner = session.username;
    let slug = fields[1].clone();
    let correlation_id = request_id.0.clone();
    let owner_for_job = owner.clone();
    let slug_for_job = slug.clone();
    let result = repository_job(state, move |repositories| {
        repositories.create_for_account(
            &owner_for_job,
            &slug_for_job,
            gix::hash::Kind::Sha1,
            &correlation_id,
        )
    })
    .await;
    match result {
        Ok(_) => Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, format!("/{owner}/{slug}"))
            .body(Body::empty())
            .expect("the repository redirect is valid"),
        Err(RepositoryServiceError::Auth(_)) | Err(RepositoryServiceError::RepositoryName(_)) => {
            repository_form_error(
                &request_id.0,
                StatusCode::BAD_REQUEST,
                "The repository name is not valid.",
            )
        }
        Err(RepositoryServiceError::Store(StoreError::RepositoryExists(_, _))) => {
            repository_form_error(
                &request_id.0,
                StatusCode::CONFLICT,
                "A repository with this name already exists.",
            )
        }
        Err(_) => repository_form_error(
            &request_id.0,
            StatusCode::INTERNAL_SERVER_ERROR,
            "The repository could not be created.",
        ),
    }
}

fn repository_form_error(request_id: &str, status: StatusCode, message: &str) -> Response {
    render_error(status, request_id, "Repository error", message)
}

async fn logout_form(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
) -> Response {
    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(false);
    };
    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
        return login_redirect(true);
    };
    let csrf_for_auth = csrf.clone();
    let result = login_job(state, move |login| {
        login.authenticate(&session_token, Some(&csrf_for_auth))
    })
    .await;
    match result {
        Ok(_) => render(
            StatusCode::OK,
            &LogoutTemplate {
                request_id: &request_id.0,
                csrf: &csrf,
                signed_in: true,
            },
        ),
        Err(_) => login_redirect(true),
    }
}

async fn logout(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "confirm"]) {
        Ok(fields) => fields,
        Err(()) => {
            return render_error(
                StatusCode::FORBIDDEN,
                &request_id.0,
                "Forbidden",
                "The request is not authorized.",
            );
        }
    };
    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
        return login_redirect(true);
    };
    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
        return login_redirect(true);
    };
    if fields[0] != csrf {
        return render_error(
            StatusCode::FORBIDDEN,
            &request_id.0,
            "Forbidden",
            "The request is not authorized.",
        );
    }
    if fields[1] != "yes" {
        return Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/account")
            .header(header::CACHE_CONTROL, "no-store")
            .body(Body::empty())
            .expect("the account redirect is valid");
    }
    let result = login_job(state, move |login| {
        let session = login.authenticate(&session_token, Some(&csrf))?;
        login.end_all(&session.username)
    })
    .await;
    if result.is_err() {
        return login_redirect(true);
    }
    login_redirect(true)
}

async fn login_job<T: Send + 'static>(
    state: WebState,
    operation: impl FnOnce(WebLoginService) -> Result<T, SessionError> + Send + 'static,
) -> Result<T, SessionError> {
    let login = state.login.ok_or(SessionError::Unavailable)?;
    let permit = state
        .jobs
        .acquire_owned()
        .await
        .map_err(|_| SessionError::Unavailable)?;
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        operation(login)
    })
    .await
    .map_err(|_| SessionError::Unavailable)?
}

async fn repository_job<T: Send + 'static>(
    state: WebState,
    operation: impl FnOnce(RepositoryService) -> Result<T, RepositoryServiceError> + Send + 'static,
) -> Result<T, RepositoryServiceError> {
    let repositories = state.repositories.ok_or_else(|| {
        RepositoryServiceError::Store(StoreError::Integrity(
            "repository service is unavailable".to_owned(),
        ))
    })?;
    let permit = state.jobs.acquire_owned().await.map_err(|_| {
        RepositoryServiceError::Store(StoreError::Integrity(
            "repository worker pool is unavailable".to_owned(),
        ))
    })?;
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        operation(repositories)
    })
    .await
    .map_err(|_| {
        RepositoryServiceError::Store(StoreError::Integrity("repository worker failed".to_owned()))
    })?
}

async fn authenticate_mutation(
    state: WebState,
    headers: &HeaderMap,
    submitted_csrf: &str,
    request_id: &str,
) -> Result<String, Response> {
    let Some(session_token) = cookie(headers, SESSION_COOKIE) else {
        return Err(login_redirect(false));
    };
    let Some(csrf) = cookie(headers, CSRF_COOKIE) else {
        return Err(login_redirect(true));
    };
    if submitted_csrf != csrf {
        return Err(render_error(
            StatusCode::FORBIDDEN,
            request_id,
            "Forbidden",
            "The request is not authorized.",
        ));
    }
    login_job(state, move |login| {
        login.authenticate(&session_token, Some(&csrf))
    })
    .await
    .map(|session| session.username)
    .map_err(|_| login_redirect(true))
}

fn parse_named_form(
    headers: &HeaderMap,
    body: &[u8],
    expected: &[&str],
) -> Result<Vec<String>, ()> {
    if headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.split(';').next())
        != Some("application/x-www-form-urlencoded")
        || !valid_percent_encoding(body)
    {
        return Err(());
    }
    let mut values = vec![None; expected.len()];
    for (name, value) in url::form_urlencoded::parse(body) {
        let Some(index) = expected.iter().position(|candidate| *candidate == name) else {
            return Err(());
        };
        if values[index].is_some() {
            return Err(());
        }
        values[index] = Some(value.into_owned());
    }
    values.into_iter().collect::<Option<Vec<_>>>().ok_or(())
}

fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
    let value = headers.get(header::COOKIE)?.to_str().ok()?;
    let mut found = None;
    for pair in value.split(';') {
        let (candidate, value) = pair.trim().split_once('=')?;
        if candidate == name {
            if found.is_some() || value.is_empty() || value.len() > 128 {
                return None;
            }
            found = Some(value.to_owned());
        }
    }
    found
}

fn append_cookie(
    headers: &mut HeaderMap,
    name: &str,
    value: &str,
    http_only: bool,
    secure: bool,
    max_age: i64,
) {
    let mut cookie = format!("{name}={value}; Path=/; SameSite=Strict; Max-Age={max_age}");
    if http_only {
        cookie.push_str("; HttpOnly");
    }
    if secure {
        cookie.push_str("; Secure");
    }
    headers.append(
        header::SET_COOKIE,
        HeaderValue::from_str(&cookie).expect("the session cookie is a header value"),
    );
}

fn login_redirect(clear: bool) -> Response {
    let mut response = Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, "/login")
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::empty())
        .expect("the login redirect is valid");
    if clear {
        append_cookie(response.headers_mut(), SESSION_COOKIE, "", true, false, 0);
        append_cookie(response.headers_mut(), CSRF_COOKIE, "", false, false, 0);
    }
    response
}

fn account_redirect() -> Response {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, "/account")
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::empty())
        .expect("the account redirect is valid")
}

fn login_error(request_id: &str, username: &str, error: &str) -> Response {
    render(
        StatusCode::BAD_REQUEST,
        &LoginTemplate {
            request_id,
            username,
            error,
            has_error: true,
            signed_in: false,
        },
    )
}

async fn rejected_login(
    state: WebState,
    request_id: &str,
    username: &str,
    error: &str,
) -> Response {
    let username_for_audit = username.to_owned();
    let correlation_id = request_id.to_owned();
    let _ = login_job(state, move |login| {
        login.record_login_failure(&username_for_audit, &correlation_id)
    })
    .await;
    login_error(request_id, username, error)
}

async fn account_job<T: Send + 'static>(
    state: WebState,
    operation: impl FnOnce(AccountService) -> Result<T, AccountError> + Send + 'static,
) -> Result<T, AccountError> {
    let accounts = state.accounts.ok_or_else(|| {
        AccountError::Store(StoreError::Integrity(
            "account service is unavailable".to_owned(),
        ))
    })?;
    let permit = state.jobs.acquire_owned().await.map_err(|_| {
        AccountError::Store(StoreError::Integrity(
            "account worker pool is unavailable".to_owned(),
        ))
    })?;
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        let result = operation(accounts.clone());
        if result.is_ok()
            && let Some(reload) = state.key_reloader
        {
            reload(&accounts)?;
        }
        result
    })
    .await
    .map_err(|_| AccountError::Store(StoreError::Integrity("account worker failed".to_owned())))?
}

fn account_result(
    result: Result<String, AccountError>,
    request_id: &str,
    kind: AccountFormKind,
    username: &str,
) -> Response {
    match result {
        Ok(recovery) => render(
            StatusCode::OK,
            &RecoveryCodeTemplate {
                request_id,
                recovery: &recovery,
                signed_in: false,
            },
        ),
        Err(AccountError::Store(StoreError::UsernameUnavailable(_))) => render_account_error(
            request_id,
            kind,
            username,
            "That username is not available.",
            StatusCode::CONFLICT,
        ),
        Err(
            AccountError::Auth(_)
            | AccountError::InvalidSecret
            | AccountError::Store(StoreError::InvalidInvitation)
            | AccountError::Store(StoreError::InvalidRecovery),
        ) => render_account_error(
            request_id,
            kind,
            username,
            "The credential, username, or SSH public key is not valid.",
            StatusCode::BAD_REQUEST,
        ),
        Err(_) => render_error(
            StatusCode::INTERNAL_SERVER_ERROR,
            request_id,
            "Internal server error",
            "The account request could not be completed.",
        ),
    }
}

fn parse_account_form(
    headers: &HeaderMap,
    body: &[u8],
    credential_name: &str,
) -> Result<AccountForm, ()> {
    if headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.split(';').next())
        != Some("application/x-www-form-urlencoded")
        || !valid_percent_encoding(body)
    {
        return Err(());
    }
    let mut username = None;
    let mut credential = None;
    let mut public_key = None;
    for (name, value) in url::form_urlencoded::parse(body) {
        match name.as_ref() {
            "username" if username.is_none() => username = Some(value.into_owned()),
            name if name == credential_name && credential.is_none() => {
                credential = Some(value.into_owned());
            }
            "public-key" if public_key.is_none() => public_key = Some(value.into_owned()),
            _ => return Err(()),
        }
    }
    Ok(AccountForm {
        username: username.ok_or(())?,
        credential: credential.ok_or(())?,
        public_key: public_key.ok_or(())?,
    })
}

fn render_account_form(
    request_id: &str,
    kind: AccountFormKind,
    username: &str,
    error: &str,
) -> Response {
    render_account_error(request_id, kind, username, error, StatusCode::OK)
}

fn render_account_error(
    request_id: &str,
    kind: AccountFormKind,
    username: &str,
    error: &str,
    status: StatusCode,
) -> Response {
    render(
        status,
        &AccountFormTemplate {
            request_id,
            username,
            error,
            has_error: !error.is_empty(),
            recovery: matches!(kind, AccountFormKind::Recovery),
            signed_in: false,
        },
    )
}

struct AccountForm {
    username: String,
    credential: String,
    public_key: String,
}

#[derive(Clone, Copy)]
enum AccountFormKind {
    Signup,
    Recovery,
}

async fn style() -> Response {
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-cache")
        .body(Body::from(STYLE))
        .expect("the embedded CSS response is valid")
}

async fn not_found(
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
) -> Response {
    render_error_with_auth(
        StatusCode::NOT_FOUND,
        &request_id.0,
        "Page not found",
        "The requested page does not exist.",
        actor.0.is_some(),
    )
}

async fn method_not_allowed(
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    OriginalUri(uri): OriginalUri,
) -> Response {
    let mut response = render_error_with_auth(
        StatusCode::METHOD_NOT_ALLOWED,
        &request_id.0,
        "Method not allowed",
        "This page does not accept the request method.",
        actor.0.is_some(),
    );
    let allow = match uri.path() {
        "/signup" | "/recover" | "/login" | "/logout" => "GET, HEAD, POST",
        "/login/verify" | "/login/verify-file" => "POST",
        path if path.ends_with("/git-upload-pack") => "POST",
        _ => "GET, HEAD",
    };
    response.headers_mut().insert(
        header::ALLOW,
        HeaderValue::from_str(allow).expect("the method list is a header value"),
    );
    response
}

async fn response_policy(
    State(state): State<WebState>,
    mut request: Request,
    next: Next,
) -> Response {
    let request_id = RequestId(format!("{:032x}", rand::random::<u128>()));
    let method = logged_method(request.method());
    let started = Instant::now();
    let _in_flight = state.telemetry.http_start();
    let is_head = request.method() == Method::HEAD;
    request.extensions_mut().insert(request_id.clone());
    let mut response = next.run(request).await;
    let headers = response.headers_mut();
    headers.insert(
        HeaderName::from_static("content-security-policy"),
        HeaderValue::from_static(CONTENT_SECURITY_POLICY),
    );
    headers.insert(
        HeaderName::from_static("x-content-type-options"),
        HeaderValue::from_static("nosniff"),
    );
    headers.insert(
        HeaderName::from_static("x-frame-options"),
        HeaderValue::from_static("DENY"),
    );
    headers.insert(
        HeaderName::from_static("referrer-policy"),
        HeaderValue::from_static("no-referrer"),
    );
    headers.insert(
        HeaderName::from_static("permissions-policy"),
        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
    );
    headers.insert(
        HeaderName::from_static("cross-origin-opener-policy"),
        HeaderValue::from_static("same-origin"),
    );
    headers.insert(
        HeaderName::from_static("x-request-id"),
        HeaderValue::from_str(&request_id.0).expect("the generated request ID is a header value"),
    );

    if is_head {
        let length = response.body().size_hint().exact();
        *response.body_mut() = Body::empty();
        if let Some(length) = length
            && !response.headers().contains_key(header::CONTENT_LENGTH)
        {
            response.headers_mut().insert(
                header::CONTENT_LENGTH,
                HeaderValue::from_str(&length.to_string())
                    .expect("a content length is a header value"),
            );
        }
    }
    state.telemetry.http_finish(
        &request_id.0,
        method,
        response.status().as_u16(),
        started.elapsed(),
    );
    response
}

fn logged_method(method: &Method) -> &'static str {
    match *method {
        Method::GET => "GET",
        Method::HEAD => "HEAD",
        Method::POST => "POST",
        Method::PUT => "PUT",
        Method::PATCH => "PATCH",
        Method::DELETE => "DELETE",
        Method::OPTIONS => "OPTIONS",
        Method::CONNECT => "CONNECT",
        Method::TRACE => "TRACE",
        _ => "OTHER",
    }
}

fn render_home(status: StatusCode, request_id: &str, page: HomePage<'_>) -> Response {
    render(
        status,
        &HomeTemplate {
            request_id,
            signed_in: page.signed_in,
            username: page.username,
            csrf: page.csrf,
            repositories: page
                .repositories
                .iter()
                .map(|repository| HomeRepositoryView {
                    owner: &repository.owner,
                    slug: &repository.slug,
                    visibility: &repository.visibility,
                    state: &repository.state,
                    description: &repository.description,
                    updated_at: repository.updated_at,
                })
                .collect(),
            recent_repositories: page
                .recent_repositories
                .iter()
                .map(|repository| HomeRepositoryView {
                    owner: &repository.owner,
                    slug: &repository.slug,
                    visibility: &repository.visibility,
                    state: &repository.state,
                    description: &repository.description,
                    updated_at: repository.updated_at,
                })
                .collect(),
        },
    )
}

struct HomePage<'a> {
    signed_in: bool,
    username: &'a str,
    csrf: &'a str,
    repositories: &'a [crate::store::HomeRepositoryRecord],
    recent_repositories: &'a [crate::store::HomeRepositoryRecord],
}

fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
    render_error_with_auth(status, request_id, heading, message, false)
}

fn render_error_with_auth(
    status: StatusCode,
    request_id: &str,
    heading: &str,
    message: &str,
    signed_in: bool,
) -> Response {
    render(
        status,
        &ErrorTemplate {
            request_id,
            status: heading,
            message,
            signed_in,
        },
    )
}

fn render(status: StatusCode, template: &impl Template) -> Response {
    match template.render() {
        Ok(body) => Response::builder()
            .status(status)
            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
            .header(header::CACHE_CONTROL, "no-store")
            .body(Body::from(body))
            .expect("the HTML response is valid"),
        Err(_) => Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
            .header(header::CACHE_CONTROL, "no-store")
            .body(Body::from("Template rendering failed.\n"))
            .expect("the template error response is valid"),
    }
}

fn valid_percent_encoding(input: &[u8]) -> bool {
    let mut index = 0;
    while index < input.len() {
        if input[index] == b'%' {
            if index + 2 >= input.len()
                || !input[index + 1].is_ascii_hexdigit()
                || !input[index + 2].is_ascii_hexdigit()
            {
                return false;
            }
            index += 3;
        } else {
            index += 1;
        }
    }
    true
}

#[derive(Clone)]
struct RequestId(String);

#[derive(Default, serde::Deserialize)]
struct ProfileQuery {
    page: Option<usize>,
}

#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate<'a> {
    request_id: &'a str,
    signed_in: bool,
    username: &'a str,
    csrf: &'a str,
    repositories: Vec<HomeRepositoryView<'a>>,
    recent_repositories: Vec<HomeRepositoryView<'a>>,
}

struct HomeRepositoryView<'a> {
    owner: &'a str,
    slug: &'a str,
    visibility: &'a str,
    state: &'a str,
    description: &'a str,
    updated_at: i64,
}

#[derive(Template)]
#[template(path = "error.html")]
struct ErrorTemplate<'a> {
    request_id: &'a str,
    status: &'a str,
    message: &'a str,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "account.html")]
struct AccountFormTemplate<'a> {
    request_id: &'a str,
    username: &'a str,
    error: &'a str,
    has_error: bool,
    recovery: bool,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "recovery-code.html")]
struct RecoveryCodeTemplate<'a> {
    request_id: &'a str,
    recovery: &'a str,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate<'a> {
    request_id: &'a str,
    username: &'a str,
    error: &'a str,
    has_error: bool,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "login-challenge.html")]
struct LoginChallengeTemplate<'a> {
    request_id: &'a str,
    username: &'a str,
    challenge: &'a str,
    login_csrf: &'a str,
    error: &'a str,
    has_error: bool,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "login-ssh.html")]
struct LoginSshTemplate<'a> {
    request_id: &'a str,
    command: &'a str,
    secret: &'a str,
    login_csrf: &'a str,
    error: &'a str,
    has_error: bool,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "account-page.html")]
struct AccountTemplate<'a> {
    request_id: &'a str,
    username: &'a str,
    administrator: bool,
    csrf: &'a str,
    bio: &'a str,
    contact_email: &'a str,
    keys: Vec<AccountKeyView<'a>>,
    active_key_count: usize,
    signed_in: bool,
}

struct AccountKeyView<'a> {
    label: &'a str,
    fingerprint: &'a str,
    created_at: String,
    last_used_at: String,
    active: bool,
}

#[derive(Template)]
#[template(path = "account-key-auth.html")]
struct AccountKeyAuthTemplate<'a> {
    request_id: &'a str,
    heading: &'a str,
    action: &'a str,
    command: &'a str,
    secret: &'a str,
    csrf: &'a str,
    label: &'a str,
    public_key: &'a str,
    fingerprint: &'a str,
    adding: bool,
    signed_in: bool,
}

#[derive(Template)]
#[template(path = "profile.html")]
struct PublicProfileTemplate<'a> {
    request_id: &'a str,
    signed_in: bool,
    username: &'a str,
    bio: &'a str,
    contact_email: &'a str,
    repositories: Vec<HomeRepositoryView<'a>>,
    has_previous: bool,
    has_next: bool,
    previous_page: usize,
    next_page: usize,
}

#[derive(Template)]
#[template(path = "logout.html")]
struct LogoutTemplate<'a> {
    request_id: &'a str,
    csrf: &'a str,
    signed_in: bool,
}

#[derive(Debug, Error)]
pub(crate) enum WebError {
    #[error("HTTP listener error: {0}")]
    Io(#[from] std::io::Error),
    #[error("public Web configuration error: {0}")]
    Public(#[from] public::PublicWebError),
    #[error("canonical URL error: {0}")]
    CanonicalUrl(url::ParseError),
    #[error("public Web configuration error: {0}")]
    PublicConfig(String),
    #[error(transparent)]
    Session(#[from] SessionError),
    #[error("HTTP server task failed")]
    Join,
}