//! `sorceryd`: serves generated sites and git data from behind nginx. use std::fs; use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use arc_swap::ArcSwap; use axum::Router; use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{any, get, post}; use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, watch}; use crate::catalog::Repository; use crate::highlight::Cache as GrammarCache; use cache::{RepoEntry, Snapshot}; mod cache; mod git; mod objects; mod site; #[derive(Clone)] pub struct Config { pub repositories: PathBuf, pub cache: PathBuf, pub socket: PathBuf, pub instance_name: String, pub internal_prefix: String, /// Internal location rooted at the *repositories* dir, for `.git` serving. pub git_internal_prefix: String, pub check_interval: Duration, pub max_git_processes: usize, pub refresh_token_file: Option, /// Public base URL (e.g. `https://git.example.org`); enables the clone /// command on repository pages. pub clone_url_base: Option, } impl Config { fn repo_cache(&self, repository: &Repository) -> PathBuf { self.cache.join(&repository.user).join(&repository.name) } } type AppState = Arc; struct Inner { config: Config, /// Immutable view of the repository catalog, replaced wholesale by the /// background scanner. Request handlers only ever read it. snapshot: ArcSwap, /// Builds hold this shared; garbage collection needs it exclusively. cache_lock: RwLock<()>, background: watch::Sender>, git_processes: Arc, refresh_token: Option>, highlights: Arc, } type WebError = (StatusCode, String); type WebResult = Result; pub async fn run(config: Config) -> Result<()> { if config.instance_name.trim().is_empty() { anyhow::bail!("--instance-name must not be empty"); } if !config.internal_prefix.starts_with('/') { anyhow::bail!("--internal-prefix must start with /"); } if config.max_git_processes == 0 { anyhow::bail!("--max-git-processes must be greater than zero"); } fs::create_dir_all(&config.cache)?; site::stage_assets(&config.cache)?; if let Some(parent) = config.socket.parent() { fs::create_dir_all(parent)?; } if let Ok(metadata) = fs::symlink_metadata(&config.socket) { if !metadata.file_type().is_socket() { anyhow::bail!("refusing to replace non-socket {}", config.socket.display()); } fs::remove_file(&config.socket)?; } let refresh_token = config .refresh_token_file .as_deref() .map(read_refresh_token) .transpose()?; let listener = tokio::net::UnixListener::bind(&config.socket) .with_context(|| format!("binding {}", config.socket.display()))?; let socket = config.socket.clone(); let (background, jobs) = watch::channel(Vec::new()); let state = Arc::new(Inner { snapshot: ArcSwap::from_pointee(Snapshot::default()), cache_lock: RwLock::new(()), background, git_processes: Arc::new(Semaphore::new(config.max_git_processes)), refresh_token, highlights: Arc::new(GrammarCache::new(config.cache.join(".arborium"))), config, }); tokio::spawn(cache::background_worker(state.clone(), jobs)); state.rescan().await.context("initial repository scan")?; tokio::spawn(cache::scan_periodically(state.clone())); let app = Router::new() .route("/", get(site::index)) .route("/css/style.css", get(site::stylesheet)) .route("/js/{*path}", get(site::js_asset)) .route("/fonts/{*path}", get(site::font_asset)) .route("/-/refresh/{user}/{repo}", post(cache::refresh)) .route("/{user}/{repo}", get(site::add_trailing_slash)) .route("/{user}/{repo}/", get(site::repo_root)) .route("/{user}/{repo}/.git/{*path}", get(git::git_dir)) .route("/{user}/{repo}/info/refs", get(git::info_refs)) .route("/{user}/{repo}/git-upload-pack", post(git::upload_pack)) .route("/{user}/{repo}/obj", any(objects::query)) .route("/{user}/{repo}/raw/{oid}/{*path}", get(git::raw_blob)) .route("/{user}/{repo}/{*path}", get(site::repo_path)) .with_state(state); let result = axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await; if socket.exists() { fs::remove_file(socket)?; } result?; Ok(()) } fn read_refresh_token(path: &Path) -> Result> { let token = fs::read(path).with_context(|| format!("reading refresh token {}", path.display()))?; let token = token.strip_suffix(b"\n").unwrap_or(&token); let token = token.strip_suffix(b"\r").unwrap_or(token); if token.is_empty() || !token.iter().all(u8::is_ascii_graphic) { anyhow::bail!("refresh token must contain visible ASCII without whitespace"); } Ok(token.to_vec()) } async fn shutdown_signal() { let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("installing SIGTERM handler"); tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = terminate.recv() => {} } } impl Inner { /// Look up a repository in the current snapshot, tolerating a `.git` /// suffix on the request (so `git clone .../user/repo.git` works too). fn resolve(&self, user: &str, name: &str) -> WebResult { let name = name.strip_suffix(".git").unwrap_or(name); self.snapshot .load() .repos .get(&format!("{user}/{name}")) .cloned() .ok_or_else(|| (StatusCode::NOT_FOUND, "repository not found".into())) } fn git_permit(&self) -> WebResult { self.git_processes.clone().try_acquire_owned().map_err(|_| { ( StatusCode::SERVICE_UNAVAILABLE, "git service is busy".into(), ) }) } } /// Hand the (already percent-encoded) path beneath `prefix` to nginx. fn x_accel(prefix: &str, encoded_path: &str) -> WebResult { let location = format!("{}/{encoded_path}", prefix.trim_end_matches('/')); let location = HeaderValue::from_str(&location).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "invalid internal path".into(), ) })?; Ok([("x-accel-redirect", location)].into_response()) } fn internal(error: impl std::fmt::Display) -> WebError { eprintln!("sorceryd: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into(), ) }