// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The web UI: server-rendered `maud` templates enhanced with htmx. //! //! [`AppState`] is the shared handle every route clones cheaply. [`serve`] builds //! the router, wires the tower-http middleware, and runs the server with graceful //! shutdown and a `SIGHUP` theme re-scan. Handlers live in [`pages`] (HTML) and //! [`serve`](mod@serve_assets) (assets/health). Every interactive element works //! without JavaScript; htmx only enhances. mod activity; mod admin; mod assets; mod avatar; mod diff; mod error; mod git_http; mod groups; mod icons; mod issues; mod languages; mod layout; mod lfs; mod license; mod markdown; mod notifications; mod pages; mod pulls; mod releases; mod repo; mod search; #[path = "serve.rs"] mod serve_assets; mod session; #[cfg(test)] mod tests; use std::net::SocketAddr; use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard}; use axum::Router; use axum::http::header::HeaderName; use axum::routing::{get, post}; use config::Config; use mail::Mailer; use store::Store; use tower::ServiceBuilder; use tower_http::compression::CompressionLayer; use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::trace::TraceLayer; use crate::assets::Assets; pub use crate::error::{AppError, AppResult}; /// An error building or running the web server. #[derive(Debug, thiserror::Error)] pub enum WebError { /// The mailer could not be constructed from configuration. #[error("mail configuration error: {0}")] Mail(String), /// The upload storage backend could not be constructed from configuration. #[error("storage configuration error: {0}")] Blob(String), /// The server socket failed to bind or serve. #[error(transparent)] Io(#[from] std::io::Error), } /// The shared application state, cloned into every request. All fields are cheap /// to clone (reference-counted or pooled). #[derive(Clone)] pub struct AppState { /// The effective configuration. pub config: Arc, /// The database handle. pub store: Store, /// Upload storage (avatars, release assets, LFS) — local disk or S3. pub blobs: blob::BlobStore, /// The configured mailer (invite emails). pub mailer: Arc, /// The instance signing secret (API tokens, future session signing). pub secret: Arc, /// Commit/tag signature-verification cache (verification is expensive; list /// views reuse results, bounded and TTL'd). pub signatures: git::SignatureCache, /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it. assets: Arc>, /// Admin-set instance settings that override the config file, cached in /// memory and refreshed via [`AppState::reload_settings`]. settings: Arc>>, } /// Keys for the admin-overridable instance settings. pub(crate) mod setting_keys { /// Instance display name. pub const NAME: &str = "instance.name"; /// Instance description. pub const DESCRIPTION: &str = "instance.description"; /// Allow web self-registration. pub const ALLOW_REGISTRATION: &str = "instance.allow_registration"; /// Allow anonymous browse/clone. pub const ALLOW_ANONYMOUS: &str = "instance.allow_anonymous"; /// Default visibility for new repositories. pub const DEFAULT_VISIBILITY: &str = "instance.default_visibility"; } impl AppState { /// Build the shared state from configuration and an open store. /// /// # Errors /// /// Returns [`WebError::Mail`] if the mailer cannot be constructed, or /// [`WebError::Blob`] if the upload storage backend is misconfigured. pub fn build(config: Arc, store: Store, secret: String) -> Result { let mailer = Mailer::build(&config.mail).map_err(|e| WebError::Mail(e.to_string()))?; let blobs = blob::BlobStore::from_config(&config.storage) .map_err(|e| WebError::Blob(e.to_string()))?; let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone()); Ok(Self { config, store, blobs, mailer: Arc::new(mailer), secret: Arc::new(secret), signatures: git::SignatureCache::new(4096), assets: Arc::new(RwLock::new(assets)), settings: Arc::new(RwLock::new(std::collections::HashMap::new())), }) } /// Refresh the cached instance settings from the database. Call at startup and /// after any admin settings change. pub async fn reload_settings(&self) { if let Ok(map) = self.store.all_settings().await { *self .settings .write() .unwrap_or_else(PoisonError::into_inner) = map; } } /// A settings override for `key`, if an admin has set one. fn setting(&self, key: &str) -> Option { self.settings .read() .unwrap_or_else(PoisonError::into_inner) .get(key) .cloned() } /// A string setting: admin override, else `fallback` (the config-file value). pub(crate) fn setting_str(&self, key: &str, fallback: &str) -> String { self.setting(key).unwrap_or_else(|| fallback.to_string()) } /// A boolean setting: admin override (`"true"`/`"false"`), else `fallback`. pub(crate) fn setting_bool(&self, key: &str, fallback: bool) -> bool { self.setting(key).map_or(fallback, |v| v == "true") } /// Whether web self-registration is currently allowed (override or config). pub(crate) fn allow_registration(&self) -> bool { self.setting_bool( setting_keys::ALLOW_REGISTRATION, self.config.instance.allow_registration, ) } /// Whether anonymous browse/clone is currently allowed (override or config). pub(crate) fn allow_anonymous(&self) -> bool { self.setting_bool( setting_keys::ALLOW_ANONYMOUS, self.config.instance.allow_anonymous, ) } /// The effective instance display name (override or config). pub(crate) fn instance_name(&self) -> String { self.setting_str(setting_keys::NAME, &self.config.instance.name) } /// The effective default-visibility token for new repos (override or config). pub(crate) fn default_visibility_token(&self) -> String { self.setting_str( setting_keys::DEFAULT_VISIBILITY, self.config.instance.default_visibility.as_token(), ) } /// A read guard over the asset registry, recovering from a poisoned lock. fn assets(&self) -> RwLockReadGuard<'_, Assets> { self.assets.read().unwrap_or_else(PoisonError::into_inner) } /// Re-scan the themes directory (invoked on `SIGHUP`). fn rescan_assets(&self) { let mut guard = self.assets.write().unwrap_or_else(PoisonError::into_inner); guard.rescan(); } } /// The current time in Unix milliseconds UTC. pub(crate) fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) } /// Build the application router with all middleware wired. #[allow(clippy::too_many_lines)] // A flat route table; splitting it hurts readability. pub fn build_router(state: AppState) -> Router { let x_request_id = HeaderName::from_static("x-request-id"); // The JSON API is a self-contained, fully-stated router nested under /api/v1. let api = api::router( state.store.clone(), Arc::clone(&state.config), (*state.secret).clone(), ); Router::new() .nest_service("/api/v1", api) .route("/", get(pages::home)) .route( "/new", get(pages::new_repo_form).post(pages::new_repo_submit), ) .route("/new/issue", get(pages::new_issue_chooser)) .route("/new/pull", get(pages::new_pull_chooser)) .route("/new/group", get(groups::new_form).post(groups::new_submit)) .route("/explore", get(pages::explore)) .route("/explore/users", get(pages::explore_users)) .route("/login", get(pages::login_form).post(pages::login_submit)) .route( "/register", get(pages::register_form).post(pages::register_submit), ) .route("/register/{token}", get(pages::register_invite_form)) .route("/logout", post(pages::logout)) .route( "/invite/{token}", get(pages::invite_form).post(pages::invite_submit), ) .route("/verify-email/{token}", get(pages::verify_email)) .route("/search", get(search::global)) .route( "/settings", get(pages::settings).post(pages::settings_submit), ) .route("/settings/account", get(pages::settings_account)) .route("/settings/username", post(pages::account_username)) .route("/settings/emails", post(pages::email_add)) .route("/settings/emails/primary", post(pages::email_primary)) .route("/settings/emails/delete", post(pages::email_delete)) .route("/settings/emails/resend", post(pages::email_resend)) .route("/settings/emails/visibility", post(pages::email_visibility)) .route("/settings/password", post(pages::account_password)) .route( "/settings/tokens", get(pages::settings_tokens).post(pages::token_create), ) .route("/settings/tokens/revoke", post(pages::token_revoke)) .route( "/settings/keys", get(pages::settings_keys).post(pages::key_add), ) .route("/settings/keys/delete", post(pages::key_delete)) .route("/settings/keys/verify", post(pages::key_verify)) .route("/settings/avatar", post(avatar::upload)) // Admin dashboard (RequireAdmin-gated in each handler). .route("/admin", get(admin::overview)) .route("/admin/users", get(admin::users).post(admin::user_create)) .route("/admin/users/password", post(admin::user_set_password)) .route("/admin/users/{id}/verify", post(admin::user_verify)) .route("/admin/users/{id}/disable", post(admin::user_disable)) .route("/admin/users/{id}/admin", post(admin::user_toggle_admin)) .route("/admin/users/{id}/delete", post(admin::user_delete)) .route("/admin/repos", get(admin::repos)) .route("/admin/repos/{id}/delete", post(admin::repo_delete)) .route("/admin/groups", get(admin::groups)) .route("/admin/groups/{id}/delete", post(admin::group_delete)) .route( "/admin/invites", get(admin::invites).post(admin::invite_create), ) .route("/admin/invites/{id}/delete", post(admin::invite_delete)) .route( "/admin/settings", get(admin::settings).post(admin::settings_save), ) .route("/settings/theme", get(pages::set_theme)) .route("/avatar/{username}", get(avatar::show)) // Repo administration (owner/admin only; enforced in the handlers). A // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all. .route("/repo-settings/{id}/general", post(repo::settings_general)) .route("/repo-settings/{id}/archive", post(repo::settings_archive)) .route("/repo-settings/{id}/delete", post(repo::settings_delete)) .route( "/repo-settings/{id}/collaborators", post(repo::settings_collaborator_add), ) .route( "/repo-settings/{id}/collaborators/remove", post(repo::settings_collaborator_remove), ) .route("/repo-settings/{id}/labels", post(repo::settings_label_add)) .route( "/repo-settings/{id}/labels/delete", post(repo::settings_label_delete), ) .route( "/repo-settings/{id}/mirror", post(repo::settings_mirror_add), ) .route( "/repo-settings/{id}/mirror/{mid}/delete", post(repo::settings_mirror_delete), ) .route( "/repo-settings/{id}/mirror/{mid}/sync", post(repo::settings_mirror_sync), ) .route( "/new/migrate", get(pages::migrate_form).post(pages::migrate_submit), ) .route( "/repo-fork/{id}", get(repo::fork_confirm).post(repo::fork_create), ) .route("/group-settings/{id}", get(groups::settings_view)) .route( "/group-settings/{id}/general", post(groups::settings_general), ) .route( "/group-settings/{id}/collaborators", post(groups::settings_collaborator_add), ) .route( "/group-settings/{id}/collaborators/remove", post(groups::settings_collaborator_remove), ) .route("/group-settings/{id}/delete", post(groups::settings_delete)) .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) .route("/user-follow/{username}", get(repo::follow_toggle)) .route("/notifications", get(notifications::list)) .route("/notifications/read-all", post(notifications::read_all)) .route("/notifications/{id}", get(notifications::open)) // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. .route("/issue-new/{repo_id}", post(issues::create)) .route("/issue/{id}/comment", post(issues::comment)) .route("/issue/{id}/state", post(issues::set_state)) .route("/issue/{id}/assignee", post(issues::set_assignee)) .route("/issue/{id}/labels", post(issues::set_labels)) .route("/issue/{id}/deps/add", post(issues::deps_add)) .route("/issue/{id}/deps/remove", post(issues::deps_remove)) .route("/issue/{id}/edit-title", post(issues::edit_title)) .route("/issue/{id}/edit-body", post(issues::edit_body)) .route("/issue/{id}/lock", post(issues::set_lock)) .route("/comment/{id}/edit", post(issues::comment_edit)) .route("/pull-new/{repo_id}", post(pulls::create)) .route("/pull/{id}/merge", post(pulls::merge)) .route("/release-new/{repo_id}", post(releases::create)) .route( "/release/{id}/edit", get(releases::edit_form).post(releases::edit), ) .route("/release/{id}/delete", post(releases::delete)) .route("/release/{id}/asset", post(releases::asset_upload)) .route("/release-asset/{id}", get(releases::asset_download)) .route("/release-asset/{id}/delete", post(releases::asset_delete)) .route("/healthz", get(serve_assets::healthz)) .route("/version", get(serve_assets::version)) .route("/theme.css", get(serve_assets::theme_default)) .route("/theme/{file}", get(serve_assets::theme_named)) .route("/assets/{*path}", get(serve_assets::assets)) // Repository browsing. Variable-length paths split on `/-/` in the handler. // The same catch-all serves smart-HTTP pack transport: GET handles // `…​.git/info/refs` inside the dispatcher; POST is the pack RPC. .route("/{owner}", get(repo::user_page)) .route( "/{owner}/{*rest}", get(repo::dispatch).post(git_http::pack_rpc).put(lfs::put), ) .layer( ServiceBuilder::new() .layer(SetRequestIdLayer::new( x_request_id.clone(), MakeRequestUuid, )) .layer(TraceLayer::new_for_http()) .layer(PropagateRequestIdLayer::new(x_request_id)) // No global request timeout: pack-transport routes (§3.3) stream // arbitrarily long clones and must never be cut off. Per-route // timeouts on non-git routes land in the polish phase. .layer(CompressionLayer::new()), ) .with_state(state) } /// Run the web server on `addr` until a shutdown signal arrives. /// /// Installs a `SIGHUP` handler that re-scans the themes directory, and drains /// in-flight requests on `SIGTERM`/`SIGINT`. /// /// # Errors /// /// Returns [`WebError::Io`] if the listener cannot bind or serving fails. pub async fn serve(state: AppState, addr: SocketAddr) -> Result<(), WebError> { init_tracing(&state.config); spawn_sighup_rescan(state.clone()); let router = build_router(state); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, "fabrica web server listening"); axum::serve(listener, router) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } /// Initialize the global tracing subscriber from the log configuration. Honours /// `RUST_LOG` when set; idempotent (a second call is a no-op). fn init_tracing(config: &Config) { use config::LogFormat; use tracing_subscriber::EnvFilter; let level = match config.log.level { config::LogLevel::Trace => "trace", config::LogLevel::Debug => "debug", config::LogLevel::Info => "info", config::LogLevel::Warn => "warn", config::LogLevel::Error => "error", }; let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); let builder = tracing_subscriber::fmt().with_env_filter(filter); // `try_init` returns Err if a subscriber is already set; ignore it. match config.log.format { LogFormat::Json => { let _ = builder.json().try_init(); } LogFormat::Pretty => { let _ = builder.try_init(); } } } /// Spawn a task that re-scans themes on every `SIGHUP` (no-op off Unix). fn spawn_sighup_rescan(state: AppState) { #[cfg(unix)] tokio::spawn(async move { use tokio::signal::unix::{SignalKind, signal}; let Ok(mut hup) = signal(SignalKind::hangup()) else { tracing::warn!("could not install SIGHUP handler; theme re-scan disabled"); return; }; while hup.recv().await.is_some() { tracing::info!("SIGHUP: re-scanning themes"); state.rescan_assets(); } }); #[cfg(not(unix))] let _ = state; } /// A future that resolves on `SIGINT` or `SIGTERM`. async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] let terminate = async { use tokio::signal::unix::{SignalKind, signal}; match signal(SignalKind::terminate()) { Ok(mut term) => { term.recv().await; } Err(_) => std::future::pending::<()>().await, } }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { () = ctrl_c => {}, () = terminate => {}, } tracing::info!("shutdown signal received; draining"); }