forks/sorcery
Mirror of https://git.t4t.associates/char/sorcery
git clone https://git.gae.moe/forks/sorcery
743f502
grace-patches
1use std:: collections:: HashSet ; 2use std:: fs; 3use std:: path::{ Component , Path , PathBuf }; 4 5use anyhow::{ Context , Result }; 6use axum:: extract::{ OriginalUri , Path as AxumPath , State }; 7use axum:: http::{ HeaderValue , StatusCode , header}; 8use axum:: response::{ IntoResponse , Redirect , Response }; 9use include_dir::{ Dir , include_dir}; 10 11use crate :: catalog:: Repository ; 12use crate :: generate::{ MANIFEST_FILE , STATE_FILE , STYLESHEET }; 13use crate :: render::{ catalog_index, encode_path, user_index}; 14 15use super ::{ AppState , WebResult , x_accel}; 16 17/// Bundle first: `cd web && deno task build`. 18static WEB_DIST : Dir < ' _ > =include_dir! ( "$CARGO_MANIFEST_DIR/web/dist" ); 19static FONTS : Dir < ' _ > =include_dir! ( "$CARGO_MANIFEST_DIR/assets/fonts" ); 20 21/// Dot-prefixed so that discovery can never yield a colliding user or repo. 22const CATALOG_DIR : & str =".catalog" ; 23 24pub ( super ) fn stage_assets ( cache : & Path ) ->Result <()> { 25// Embedded assets are served via X-Accel like everything else, so nginx 26// provides ETag/Last-Modified/304s. 27write_asset ( & cache. join ( "css/style.css" ), STYLESHEET . as_bytes ()) ?; 28write_embedded_dir ( & WEB_DIST , & cache. join ( "js" )) ?; 29write_embedded_dir ( & FONTS , & cache. join ( "fonts" )) 30} 31 32fn write_embedded_dir ( dir : & Dir < ' _ >, out : & Path ) ->Result <()> { 33for filein dir. files () { 34write_asset ( & out. join ( file. path ()), file. contents ()) ?; 35} 36for childin dir. dirs () { 37write_embedded_dir ( child, out) ?; 38} 39Ok (()) 40} 41 42/// Rewrites the catalog index and per-user pages, removing pages for users 43/// who no longer have any repositories. 44pub ( super ) fn write_catalog ( 45cache : & Path , 46instance_name : & str , 47repositories : & [ Repository ], 48) ->Result <()> { 49let catalog = cache. join ( CATALOG_DIR ); 50write_asset ( 51& catalog. join ( "index.html" ), 52catalog_index ( instance_name, repositories). as_bytes (), 53) ?; 54let users = catalog. join ( "users" ); 55let mut live =HashSet :: new (); 56for reposin repositories. chunk_by ( |a, b| a. user == b. user ) { 57let file =format! ( "{}.html" , repos[ 0 ]. user ); 58write_asset ( 59& users. join ( & file), 60user_index ( instance_name, & repos[ 0 ]. user , repos). as_bytes (), 61) ?; 62 live. insert ( file); 63} 64for entryin fs:: read_dir ( & users). into_iter (). flatten () { 65let entry = entry?; 66if !live. contains ( entry. file_name (). to_string_lossy (). as_ref ()) { 67 fs:: remove_file ( entry. path ()) ?; 68} 69} 70Ok (()) 71} 72 73fn write_asset ( path : & Path , content : & [ u8 ]) ->Result <()> { 74if fs:: read ( path). ok (). as_deref () ==Some ( content) { 75// bail if the file is unchanged so that we don't bump mtime 76// (which would upset mtime-derived ETags) 77return Ok (()); 78} 79let parent = path. parent (). expect ( "asset path has a parent" ); 80 fs:: create_dir_all ( parent) ?; 81let name = path. file_name (). expect ( "asset path has a name" ); 82// write to staging and then rename over so nginx never caches a partially-written file 83let staging = parent. join ( format! ( ".{}.new" , name. to_string_lossy ())); 84 fs:: write ( & staging, content) ?; 85 fs:: rename ( & staging, path). with_context ( ||format! ( "installing {}" , path. display ())) 86} 87 88pub ( super ) async fn index ( State ( state): State < AppState >) ->WebResult < Response > { 89serve_cache_path ( & state, & format! ( "{CATALOG_DIR}/index.html" )) 90} 91 92pub ( super ) async fn user_root ( 93State ( state): State < AppState >, 94AxumPath ( user): AxumPath < String >, 95) ->WebResult < Response > { 96if !state. snapshot . load (). users . contains ( & user) { 97return Err (( StatusCode :: NOT_FOUND , "not found" . into ())); 98} 99serve_cache_path ( 100& state, 101& format! ( "{CATALOG_DIR}/users/{}.html" , encode_path ( & user)), 102) 103} 104 105/// Unlike repos, only redirect known users: this route also catches 106/// top-level strays like `/favicon.ico`. 107pub ( super ) async fn user_redirect ( 108State ( state): State < AppState >, 109OriginalUri ( uri): OriginalUri , 110AxumPath ( user): AxumPath < String >, 111) ->WebResult < Redirect > { 112if !state. snapshot . load (). users . contains ( & user) { 113return Err (( StatusCode :: NOT_FOUND , "not found" . into ())); 114} 115Ok ( add_trailing_slash ( OriginalUri ( uri)). await ) 116} 117 118pub ( super ) async fn stylesheet ( State ( state): State < AppState >) ->WebResult < Response > { 119serve_cache_path ( & state, "css/style.css" ) 120} 121 122pub ( super ) async fn js_asset ( 123State ( state): State < AppState >, 124AxumPath ( path): AxumPath < String >, 125) ->WebResult < Response > { 126embedded_asset ( & state, & WEB_DIST , "js" , & path) 127} 128 129pub ( super ) async fn font_asset ( 130State ( state): State < AppState >, 131AxumPath ( path): AxumPath < String >, 132) ->WebResult < Response > { 133embedded_asset ( & state, & FONTS , "fonts" , & path) 134} 135 136fn embedded_asset ( 137state : & AppState , 138dir : & Dir < ' _ >, 139prefix : & str , 140path : & str , 141) ->WebResult < Response > { 142if dir. get_file ( path). is_none () { 143return Err (( StatusCode :: NOT_FOUND , "not found" . into ())); 144} 145serve_cache_path ( state, & format! ( "{prefix}/{}" , encode_path ( path))) 146} 147 148pub ( super ) async fn add_trailing_slash ( OriginalUri ( uri): OriginalUri ) ->Redirect { 149Redirect :: permanent ( & format! ( "{}/" , uri. path ())) 150} 151 152pub ( super ) async fn repo_root ( 153State ( state): State < AppState >, 154OriginalUri ( uri): OriginalUri , 155AxumPath (( user, repo)): AxumPath <( String , String )>, 156) ->WebResult < Response > { 157serve_repo ( state, user, repo, String :: new (), uri. path (). to_owned ()). await 158} 159 160pub ( super ) async fn repo_path ( 161State ( state): State < AppState >, 162OriginalUri ( uri): OriginalUri , 163AxumPath (( user, repo, path)): AxumPath <( String , String , String )>, 164) ->WebResult < Response > { 165serve_repo ( state, user, repo, path, uri. path (). to_owned ()). await 166} 167 168async fn serve_repo ( 169state : AppState , 170user : String , 171name : String , 172path : String , 173request_path : String , 174) ->WebResult < Response > { 175let entry = state. resolve ( & user, & name) ?; 176let relative =requested_file ( & path) ?; 177let output = state. config . repo_cache ( & entry. repository ); 178if !output. join ( STATE_FILE ). is_file () { 179return Ok (( 180StatusCode :: SERVICE_UNAVAILABLE , 181[( header:: RETRY_AFTER , HeaderValue :: from_static ( "10" ))], 182"this repository's site is still being generated" , 183) 184. into_response ()); 185} 186 187let target = output. join ( & relative); 188if target. is_dir () { 189return Ok ( Redirect :: permanent ( & format! ( "{request_path}/" )). into_response ()); 190} 191if !target. is_file () { 192return Err (( StatusCode :: NOT_FOUND , "page not found" . into ())); 193} 194 195serve_cache_path ( 196& state, 197& format! ( 198"{}/{}/{}" , 199encode_path ( & entry. repository . user ), 200encode_path ( & entry. repository . name ), 201encode_path ( & relative. to_string_lossy ()), 202), 203) 204} 205 206fn serve_cache_path ( state : & AppState , encoded_path : & str ) ->WebResult < Response > { 207x_accel ( & state. config . internal_prefix , encoded_path) 208} 209 210fn requested_file ( path : & str ) ->WebResult < PathBuf > { 211if path. is_empty () { 212return Ok ( PathBuf :: from ( "index.html" )); 213} 214if path ==STATE_FILE || path ==MANIFEST_FILE { 215return Err (( StatusCode :: NOT_FOUND , "page not found" . into ())); 216} 217let directory = path. ends_with ( '/' ); 218let path =Path :: new ( path); 219if path 220. components () 221. any ( |component| !matches! ( component, Component :: Normal ( _))) 222{ 223return Err (( StatusCode :: BAD_REQUEST , "invalid path" . into ())); 224} 225Ok ( if directory{ 226 path. join ( "index.html" ) 227} else { 228 path. to_owned () 229}) 230} 231 232# [ cfg ( test )] 233mod tests{ 234use crate :: generate::{ MANIFEST_FILE , STATE_FILE }; 235 236use super :: requested_file; 237 238# [ test ] 239fn generator_metadata_is_private () { 240assert! ( requested_file ( STATE_FILE ). is_err ()); 241assert! ( requested_file ( MANIFEST_FILE ). is_err ()); 242} 243}