char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsimplify + split www daemon3665650

main
6.7 KiB198 linesraw
1//! `sorceryd`: serves generated sites and git data from behind nginx.
2
3use std::fs;
4use std::os::unix::fs::FileTypeExt;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::{Context, Result};
10use arc_swap::ArcSwap;
11use axum::Router;
12use axum::http::{HeaderValue, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::routing::{any, get, post};
15use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, watch};
16
17use crate::catalog::Repository;
18use crate::highlight::Cache as GrammarCache;
19use cache::{RepoEntry, Snapshot};
20
21mod cache;
22mod git;
23mod objects;
24mod site;
25
26#[derive(Clone)]
27pub struct Config {
28    pub repositories: PathBuf,
29    pub cache: PathBuf,
30    pub socket: PathBuf,
31    pub instance_name: String,
32    pub internal_prefix: String,
33    /// Internal location rooted at the *repositories* dir, for `.git` serving.
34    pub git_internal_prefix: String,
35    pub check_interval: Duration,
36    pub max_git_processes: usize,
37    pub refresh_token_file: Option<PathBuf>,
38    /// Public base URL (e.g. `https://git.example.org`); enables the clone
39    /// command on repository pages.
40    pub clone_url_base: Option<String>,
41}
42
43impl Config {
44    fn repo_cache(&self, repository: &Repository) -> PathBuf {
45        self.cache.join(&repository.user).join(&repository.name)
46    }
47}
48
49type AppState = Arc<Inner>;
50
51struct Inner {
52    config: Config,
53    /// Immutable view of the repository catalog, replaced wholesale by the
54    /// background scanner. Request handlers only ever read it.
55    snapshot: ArcSwap<Snapshot>,
56    /// Builds hold this shared; garbage collection needs it exclusively.
57    cache_lock: RwLock<()>,
58    background: watch::Sender<Vec<RepoEntry>>,
59    git_processes: Arc<Semaphore>,
60    refresh_token: Option<Vec<u8>>,
61    highlights: Arc<GrammarCache>,
62}
63
64type WebError = (StatusCode, String);
65type WebResult<T> = Result<T, WebError>;
66
67pub async fn run(config: Config) -> Result<()> {
68    if config.instance_name.trim().is_empty() {
69        anyhow::bail!("--instance-name must not be empty");
70    }
71    if !config.internal_prefix.starts_with('/') {
72        anyhow::bail!("--internal-prefix must start with /");
73    }
74    if config.max_git_processes == 0 {
75        anyhow::bail!("--max-git-processes must be greater than zero");
76    }
77    fs::create_dir_all(&config.cache)?;
78    site::stage_assets(&config.cache)?;
79    if let Some(parent) = config.socket.parent() {
80        fs::create_dir_all(parent)?;
81    }
82    if let Ok(metadata) = fs::symlink_metadata(&config.socket) {
83        if !metadata.file_type().is_socket() {
84            anyhow::bail!("refusing to replace non-socket {}", config.socket.display());
85        }
86        fs::remove_file(&config.socket)?;
87    }
88
89    let refresh_token = config
90        .refresh_token_file
91        .as_deref()
92        .map(read_refresh_token)
93        .transpose()?;
94    let listener = tokio::net::UnixListener::bind(&config.socket)
95        .with_context(|| format!("binding {}", config.socket.display()))?;
96    let socket = config.socket.clone();
97    let (background, jobs) = watch::channel(Vec::new());
98    let state = Arc::new(Inner {
99        snapshot: ArcSwap::from_pointee(Snapshot::default()),
100        cache_lock: RwLock::new(()),
101        background,
102        git_processes: Arc::new(Semaphore::new(config.max_git_processes)),
103        refresh_token,
104        highlights: Arc::new(GrammarCache::new(config.cache.join(".arborium"))),
105        config,
106    });
107    tokio::spawn(cache::background_worker(state.clone(), jobs));
108    state.rescan().await.context("initial repository scan")?;
109    tokio::spawn(cache::scan_periodically(state.clone()));
110
111    let app = Router::new()
112        .route("/", get(site::index))
113        .route("/css/style.css", get(site::stylesheet))
114        .route("/js/{*path}", get(site::js_asset))
115        .route("/fonts/{*path}", get(site::font_asset))
116        .route("/-/refresh/{user}/{repo}", post(cache::refresh))
117        .route("/{user}/{repo}", get(site::add_trailing_slash))
118        .route("/{user}/{repo}/", get(site::repo_root))
119        .route("/{user}/{repo}/.git/{*path}", get(git::git_dir))
120        .route("/{user}/{repo}/info/refs", get(git::info_refs))
121        .route("/{user}/{repo}/git-upload-pack", post(git::upload_pack))
122        .route("/{user}/{repo}/obj", any(objects::query))
123        .route("/{user}/{repo}/raw/{oid}/{*path}", get(git::raw_blob))
124        .route("/{user}/{repo}/{*path}", get(site::repo_path))
125        .with_state(state);
126
127    let result = axum::serve(listener, app)
128        .with_graceful_shutdown(shutdown_signal())
129        .await;
130    if socket.exists() {
131        fs::remove_file(socket)?;
132    }
133    result?;
134    Ok(())
135}
136
137fn read_refresh_token(path: &Path) -> Result<Vec<u8>> {
138    let token =
139        fs::read(path).with_context(|| format!("reading refresh token {}", path.display()))?;
140    let token = token.strip_suffix(b"\n").unwrap_or(&token);
141    let token = token.strip_suffix(b"\r").unwrap_or(token);
142    if token.is_empty() || !token.iter().all(u8::is_ascii_graphic) {
143        anyhow::bail!("refresh token must contain visible ASCII without whitespace");
144    }
145    Ok(token.to_vec())
146}
147
148async fn shutdown_signal() {
149    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
150        .expect("installing SIGTERM handler");
151    tokio::select! {
152        _ = tokio::signal::ctrl_c() => {}
153        _ = terminate.recv() => {}
154    }
155}
156
157impl Inner {
158    /// Look up a repository in the current snapshot, tolerating a `.git`
159    /// suffix on the request (so `git clone .../user/repo.git` works too).
160    fn resolve(&self, user: &str, name: &str) -> WebResult<RepoEntry> {
161        let name = name.strip_suffix(".git").unwrap_or(name);
162        self.snapshot
163            .load()
164            .repos
165            .get(&format!("{user}/{name}"))
166            .cloned()
167            .ok_or_else(|| (StatusCode::NOT_FOUND, "repository not found".into()))
168    }
169
170    fn git_permit(&self) -> WebResult<OwnedSemaphorePermit> {
171        self.git_processes.clone().try_acquire_owned().map_err(|_| {
172            (
173                StatusCode::SERVICE_UNAVAILABLE,
174                "git service is busy".into(),
175            )
176        })
177    }
178}
179
180/// Hand the (already percent-encoded) path beneath `prefix` to nginx.
181fn x_accel(prefix: &str, encoded_path: &str) -> WebResult<Response> {
182    let location = format!("{}/{encoded_path}", prefix.trim_end_matches('/'));
183    let location = HeaderValue::from_str(&location).map_err(|_| {
184        (
185            StatusCode::INTERNAL_SERVER_ERROR,
186            "invalid internal path".into(),
187        )
188    })?;
189    Ok([("x-accel-redirect", location)].into_response())
190}
191
192fn internal(error: impl std::fmt::Display) -> WebError {
193    eprintln!("sorceryd: {error}");
194    (
195        StatusCode::INTERNAL_SERVER_ERROR,
196        "internal server error".into(),
197    )
198}