char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
3665650
main
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 { 28pub repositories : PathBuf , 29pub cache : PathBuf , 30pub socket : PathBuf , 31pub instance_name : String , 32pub internal_prefix : String , 33/// Internal location rooted at the *repositories* dir, for `.git` serving. 34pub git_internal_prefix : String , 35pub check_interval : Duration , 36pub max_git_processes : usize , 37pub refresh_token_file : Option < PathBuf >, 38/// Public base URL (e.g. `https://git.example.org`); enables the clone 39/// command on repository pages. 40pub clone_url_base : Option < String >, 41} 42 43impl Config { 44fn repo_cache ( & self , repository : & Repository ) ->PathBuf { 45self . cache . join ( & repository. user ). join ( & repository. name ) 46} 47} 48 49type AppState =Arc < Inner >; 50 51struct Inner { 52config : Config , 53/// Immutable view of the repository catalog, replaced wholesale by the 54/// background scanner. Request handlers only ever read it. 55snapshot : ArcSwap < Snapshot >, 56/// Builds hold this shared; garbage collection needs it exclusively. 57cache_lock : RwLock <()>, 58background : watch:: Sender < Vec < RepoEntry >>, 59git_processes : Arc < Semaphore >, 60refresh_token : Option < Vec < u8 >>, 61highlights : Arc < GrammarCache >, 62} 63 64type WebError =( StatusCode , String ); 65type WebResult < T > =Result < T , WebError >; 66 67pub async fn run ( config : Config ) ->Result <()> { 68if config. instance_name . trim (). is_empty () { 69 anyhow:: bail!( "--instance-name must not be empty" ); 70} 71if !config. internal_prefix . starts_with ( '/' ) { 72 anyhow:: bail!( "--internal-prefix must start with /" ); 73} 74if 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 ) ?; 79if let Some ( parent) = config. socket . parent () { 80 fs:: create_dir_all ( parent) ?; 81} 82if let Ok ( metadata) = fs:: symlink_metadata ( & config. socket ) { 83if !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 89let refresh_token = config 90. refresh_token_file 91. as_deref () 92. map ( read_refresh_token) 93. transpose () ?; 94let listener = tokio:: net:: UnixListener :: bind ( & config. socket ) 95. with_context ( ||format! ( "binding {}" , config. socket . display ())) ?; 96let socket = config. socket . clone (); 97let ( background, jobs) = watch:: channel ( Vec :: new ()); 98let state =Arc :: new ( Inner { 99snapshot : ArcSwap :: from_pointee ( Snapshot :: default ()), 100cache_lock : RwLock :: new (()), 101 background, 102git_processes : Arc :: new ( Semaphore :: new ( config. max_git_processes )), 103 refresh_token, 104highlights : 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 111let 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 127let result = axum:: serve ( listener, app) 128. with_graceful_shutdown ( shutdown_signal ()) 129. await ; 130if socket. exists () { 131 fs:: remove_file ( socket) ?; 132} 133 result?; 134Ok (()) 135} 136 137fn read_refresh_token ( path : & Path ) ->Result < Vec < u8 >> { 138let token = 139 fs:: read ( path). with_context ( ||format! ( "reading refresh token {}" , path. display ())) ?; 140let token = token. strip_suffix ( b"\n" ). unwrap_or ( & token); 141let token = token. strip_suffix ( b"\r" ). unwrap_or ( token); 142if token. is_empty () || !token. iter (). all ( u8:: is_ascii_graphic) { 143 anyhow:: bail!( "refresh token must contain visible ASCII without whitespace" ); 144} 145Ok ( token. to_vec ()) 146} 147 148async fn shutdown_signal () { 149let 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). 160fn resolve ( & self , user : & str , name : & str ) ->WebResult < RepoEntry > { 161let name = name. strip_suffix ( ".git" ). unwrap_or ( name); 162self . 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 170fn git_permit ( & self ) ->WebResult < OwnedSemaphorePermit > { 171self . git_processes . clone (). try_acquire_owned (). map_err ( |_|{ 172( 173StatusCode :: 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 > { 182let location =format! ( "{}/{encoded_path}" , prefix. trim_end_matches ( '/' )); 183let location =HeaderValue :: from_str ( & location). map_err ( |_|{ 184( 185StatusCode :: INTERNAL_SERVER_ERROR , 186"invalid internal path" . into (), 187) 188}) ?; 189Ok ([( "x-accel-redirect" , location)]. into_response ()) 190} 191 192fn internal ( error : impl std:: fmt:: Display ) ->WebError { 193eprintln! ( "sorceryd: {error}" ); 194( 195StatusCode :: INTERNAL_SERVER_ERROR , 196"internal server error" . into (), 197) 198}