char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
3665650
main
1use axum:: body::{ Body , to_bytes}; 2use axum:: extract::{ Path as AxumPath , RawQuery , State }; 3use axum:: http::{ HeaderMap , HeaderValue , Request , StatusCode , header}; 4use axum:: response::{ IntoResponse , Response }; 5 6use crate :: githttp; 7use crate :: render:: encode_path; 8 9use super ::{ AppState , WebResult , internal, x_accel}; 10 11/// Read-only static access to an allowlisted subset of the bare repository, 12/// for the in-browser git client (and dumb-protocol clones). Objects are 13/// content-addressed and immutable; refs are never cached. 14pub ( super ) async fn git_dir ( 15State ( state): State < AppState >, 16AxumPath (( user, repo, path)): AxumPath <( String , String , String )>, 17) ->WebResult < Response > { 18if !allowed_git_path ( & path) { 19return Err (( StatusCode :: NOT_FOUND , "not served" . into ())); 20} 21let entry = state. resolve ( & user, & repo) ?; 22let repo_dir = entry 23. repository 24. path 25. file_name () 26. ok_or_else ( ||internal ( "repository path has no file name" )) ? 27. to_string_lossy (); 28let mut response =x_accel ( 29& state. config . git_internal_prefix , 30& format! ( 31"{}/{}/{}" , 32encode_path ( & user), 33encode_path ( & repo_dir), 34encode_path ( & path) 35), 36) ?; 37let cache_control =if path. starts_with ( "objects/" ) && path !="objects/info/packs" { 38"public, max-age=31536000, immutable" 39} else { 40"no-cache" 41}; 42 response. headers_mut (). insert ( 43 header:: CACHE_CONTROL , 44HeaderValue :: from_static ( cache_control), 45); 46Ok ( response) 47} 48 49/// `HEAD`, refs, and the object store; never `config` (may hold credentials 50/// for mirrors) or `hooks/`. 51fn allowed_git_path ( path : & str ) ->bool { 52let is_hex = |s : & str | !s. is_empty () && s. bytes (). all ( |byte| byte. is_ascii_hexdigit ()); 53match path{ 54"HEAD" |"packed-refs" |"info/refs" |"objects/info/packs" =>true , 55 pathif path. starts_with ( "refs/" ) => path 56. split ( '/' ) 57. all ( |component| !component. is_empty () && component !="." && component !=".." ), 58 path =>match path. strip_prefix ( "objects/" ) { 59Some ( rest) =>match rest. strip_prefix ( "pack/" ) { 60Some ( pack) =>{ 61let stem = pack 62. strip_suffix ( ".pack" ) 63. or_else ( || pack. strip_suffix ( ".idx" )) 64. and_then ( |stem| stem. strip_prefix ( "pack-" )); 65 stem. is_some_and ( is_hex) 66} 67None =>matches! ( 68 rest. split_once ( '/' ), 69Some (( fan, name)) if fan. len () ==2 &&is_hex ( fan) &&is_hex ( name) 70), 71}, 72None =>false , 73}, 74} 75} 76 77pub ( super ) async fn info_refs ( 78State ( state): State < AppState >, 79AxumPath (( user, repo)): AxumPath <( String , String )>, 80RawQuery ( query): RawQuery , 81headers : HeaderMap , 82) ->WebResult < Response > { 83if query. as_deref () !=Some ( "service=git-upload-pack" ) { 84return Err (( 85StatusCode :: FORBIDDEN , 86"only git-upload-pack is supported" . into (), 87)); 88} 89let entry = state. resolve ( & user, & repo) ?; 90 githttp:: advertise ( & entry. repository . path , & headers, state. git_permit () ?) 91} 92 93pub ( super ) async fn upload_pack ( 94State ( state): State < AppState >, 95AxumPath (( user, repo)): AxumPath <( String , String )>, 96request : Request < Body >, 97) ->WebResult < Response > { 98let entry = state. resolve ( & user, & repo) ?; 99let permit = state. git_permit () ?; 100let ( parts, body) = request. into_parts (); 101let body =to_bytes ( body, 64 <<20 ). await . map_err ( |_|{ 102( 103StatusCode :: PAYLOAD_TOO_LARGE , 104"git request body is too large" . into (), 105) 106}) ?; 107 githttp:: upload ( & entry. repository . path , & parts. headers , body, permit) 108} 109 110pub ( super ) async fn raw_blob ( 111State ( state): State < AppState >, 112AxumPath (( user, repo, oid, path)): AxumPath <( String , String , String , String )>, 113) ->WebResult < Response > { 114let oid = gix:: ObjectId :: from_hex ( oid. as_bytes ()) 115. map_err ( |_|( StatusCode :: NOT_FOUND , "blob not found" . into ())) ?; 116let repo_path = state. resolve ( & user, & repo) ?. repository . path ; 117let permit = state. git_permit () ?; 118let data = tokio:: task:: spawn_blocking ( move || -> anyhow:: Result < Option < Vec < u8 >>> { 119let _permit = permit; 120let repo = gix:: open ( repo_path) ?; 121let Ok ( object) = repo. find_object ( oid) else { 122return Ok ( None ); 123}; 124Ok (( object. kind == gix:: object:: Kind :: Blob ). then ( || object. data . to_vec ())) 125}) 126. await 127. map_err ( internal) ? 128. map_err ( internal) ? 129. ok_or_else ( ||( StatusCode :: NOT_FOUND , "blob not found" . into ())) ?; 130 131let content_type =raw_content_type ( & path, & data); 132let mut response =Body :: from ( data). into_response (); 133 response. headers_mut (). insert ( 134 header:: CONTENT_TYPE , 135HeaderValue :: from_str ( & content_type). map_err ( internal) ?, 136); 137 response. headers_mut (). insert ( 138 header:: CONTENT_DISPOSITION , 139HeaderValue :: from_static ( "inline" ), 140); 141 response. headers_mut (). insert ( 142"content-security-policy" , 143HeaderValue :: from_static ( 144"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'" , 145), 146); 147 response. headers_mut (). insert ( 148"x-content-type-options" , 149HeaderValue :: from_static ( "nosniff" ), 150); 151 response. headers_mut (). insert ( 152 header:: CACHE_CONTROL , 153HeaderValue :: from_static ( "public, max-age=31536000, immutable" ), 154); 155Ok ( response) 156} 157 158fn raw_content_type ( path : & str , data : & [ u8 ]) ->String { 159if std:: str:: from_utf8 ( data). is_ok () { 160return "text/plain; charset=utf-8" . into (); 161} 162 mime_guess:: from_path ( path) 163. first () 164. filter ( |mime|{ 165let essence = mime. essence_str (); 166 essence. starts_with ( "audio/" ) 167 || essence. starts_with ( "video/" ) 168 || essence. starts_with ( "image/" ) && essence !="image/svg+xml" 169}) 170. map ( |mime| mime. to_string ()) 171. unwrap_or_else ( ||"application/octet-stream" . into ()) 172} 173 174# [ cfg ( test )] 175mod tests{ 176use super :: raw_content_type; 177 178# [ test ] 179fn raw_blobs_never_render_active_content () { 180assert_eq! ( 181raw_content_type ( "page.html" , b"<script>alert(1)</script>" ), 182"text/plain; charset=utf-8" 183); 184assert_eq! ( 185raw_content_type ( "image.svg" , b"<svg><script/></svg>" ), 186"text/plain; charset=utf-8" 187); 188assert_eq! ( 189raw_content_type ( "page.html" , b"\xff\xfe<html>" ), 190"application/octet-stream" 191); 192assert_eq! ( 193raw_content_type ( "image.png" , b"\x89PNG\r\n\x1a\n" ), 194"image/png" 195); 196} 197}