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