use axum::http::HeaderValue; use axum::{ Router, body::Body, extract::{Path, State}, http::{StatusCode, Uri, header}, response::{IntoResponse, Response}, routing::get, }; use std::net::SocketAddr; use std::path::PathBuf; use tokio::{fs, sync::broadcast}; use tower_http::services::ServeDir; use tracing::{info, warn}; #[derive(Clone)] struct AppState { website_root: PathBuf, shutdown_tx: broadcast::Sender<()>, } async fn git_pull(State(state): State) -> impl IntoResponse { let fetch = tokio::process::Command::new("git") .args(["fetch", "origin"]) .current_dir(&state.website_root) .output() .await; let reset = tokio::process::Command::new("git") .args(["reset", "--hard", "origin/main"]) .current_dir(&state.website_root) .output() .await; match (fetch, reset) { (Ok(fetch_out), Ok(reset_out)) => { let success = fetch_out.status.success() && reset_out.status.success(); let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout); let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr); let reset_stdout = String::from_utf8_lossy(&reset_out.stdout); let reset_stderr = String::from_utf8_lossy(&reset_out.stderr); if !fetch_stdout.trim().is_empty() { info!("git fetch stdout: {}", fetch_stdout.trim()); } if !fetch_stderr.trim().is_empty() { warn!("git fetch stderr: {}", fetch_stderr.trim()); } if !reset_stdout.trim().is_empty() { info!("git reset stdout: {}", reset_stdout.trim()); } if !reset_stderr.trim().is_empty() { warn!("git reset stderr: {}", reset_stderr.trim()); } let body = format!( "status: {}\n\n--- fetch ---\nstdout:\n{}\nstderr:\n{}\n\n--- reset ---\nstdout:\n{}\nstderr:\n{}", if success { "ok" } else { "error" }, fetch_stdout, fetch_stderr, reset_stdout, reset_stderr ); ( if success { StatusCode::OK } else { StatusCode::INTERNAL_SERVER_ERROR }, body, ) } (Err(fetch_err), Ok(_)) => { warn!("failed to run git fetch: {}", fetch_err); ( StatusCode::INTERNAL_SERVER_ERROR, format!("git fetch failed: {}", fetch_err), ) } (Ok(_), Err(reset_err)) => { warn!("failed to run git reset: {}", reset_err); ( StatusCode::INTERNAL_SERVER_ERROR, format!("git reset failed: {}", reset_err), ) } (Err(fetch_err), Err(reset_err)) => { warn!("failed to run git fetch: {}", fetch_err); warn!("failed to run git reset: {}", reset_err); ( StatusCode::INTERNAL_SERVER_ERROR, format!( "both commands failed:\nfetch: {}\nreset: {}", fetch_err, reset_err ), ) } } } async fn shutdown(State(state): State) -> StatusCode { let _ = state.shutdown_tx.send(()); StatusCode::OK } #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); let website_root = PathBuf::from("../website"); let (shutdown_tx, _) = broadcast::channel(1); let state = AppState { website_root: website_root.clone(), shutdown_tx: shutdown_tx.clone(), }; let app = Router::new() .route("/", get(serve_index)) .route("/post/{file}", get(serve_post_page)) .route("/api/backdrops.json", get(serve_backdrops)) .route("/api/staff.json", get(serve_staff)) .route("/api/teams.json", get(serve_teams)) .route("/api/posts.json", get(serve_posts)) .route("/api/post/{file}", get(serve_post_md)) .route("/api/update", get(git_pull)) // I know this is risky .route("/api/shutdown", get(shutdown)) // once propper auth & workflows exist this should be removed .route("/directs/{name}", get(serve_direct)) .nest_service("/assets", ServeDir::new(website_root.join("assets"))) .nest_service("/posts", ServeDir::new(website_root.join("posts"))) .fallback(get(serve_static)) .with_state(state.clone()); let mut shutdown_rx = shutdown_tx.subscribe(); let http_handle = tokio::spawn(async move { let addr = SocketAddr::from(([0, 0, 0, 0], 1200)); info!("HTTP server listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(async move { let _ = shutdown_rx.recv().await; info!("Shutdown signal received"); }) .await .unwrap(); }); http_handle.await.unwrap(); } async fn serve_index(State(state): State) -> impl IntoResponse { info!("Serving index.html"); serve_file(state.website_root.join("index.html")).await } async fn serve_post_page( State(state): State, Path(_file): Path, ) -> impl IntoResponse { info!("Serving post.html"); serve_file(state.website_root.join("post.html")).await } async fn serve_backdrops(State(state): State) -> impl IntoResponse { info!("Serving backdrops.json"); serve_file(state.website_root.join("api/backdrops.json")).await } async fn serve_staff(State(state): State) -> impl IntoResponse { info!("Serving staff.json"); serve_file(state.website_root.join("api/staff.json")).await } async fn serve_teams(State(state): State) -> impl IntoResponse { info!("Serving teams.json"); serve_file(state.website_root.join("api/teams.json")).await } async fn serve_posts(State(state): State) -> impl IntoResponse { info!("Serving posts.json"); serve_file(state.website_root.join("api/posts.json")).await } async fn serve_post_md( State(state): State, Path(file): Path, ) -> impl IntoResponse { info!("Serving {}", file); let path = state.website_root.join("posts").join(&file); if path.exists() { let content = fs::read_to_string(path).await.unwrap_or_default(); Response::builder() .header(header::CONTENT_TYPE, "text/markdown; charset=utf-8") .body(Body::from(content)) .unwrap() } else { StatusCode::NOT_FOUND.into_response() } } async fn serve_static(uri: Uri, State(state): State) -> impl IntoResponse { let path = uri.path().trim_start_matches('/'); let file_path = state.website_root.join(path); info!("Serving static file, {}", path); if file_path.exists() && file_path.is_file() { serve_file(file_path).await } else { StatusCode::NOT_FOUND.into_response() } } async fn serve_file(path: PathBuf) -> Response { if !path.exists() { return StatusCode::NOT_FOUND.into_response(); } let content = match fs::read(&path).await { Ok(c) => c, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; let mime = match path.extension().and_then(|e| e.to_str()) { Some("html") => "text/html", Some("css") => "text/css", Some("js") => "application/javascript", Some("json") => "application/json", Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", Some("mp4") => "video/mp4", Some("md") => "text/markdown; charset=utf-8", _ => "application/octet-stream", }; Response::builder() .header(header::CONTENT_TYPE, mime) .body(Body::from(content)) .unwrap() } async fn serve_direct( State(state): State, Path(name): Path, ) -> impl IntoResponse { info!("Serving redirect {}", name); if name.contains('/') || name.contains('\\') || name.starts_with('.') { return StatusCode::BAD_REQUEST.into_response(); } let path = state.website_root.join("directs").join(&name); if !path.exists() || !path.is_file() { return StatusCode::NOT_FOUND.into_response(); } let content = match fs::read_to_string(&path).await { Ok(c) => c.trim().to_string(), Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; if content.is_empty() { return StatusCode::NO_CONTENT.into_response(); } if !content.starts_with("http://") && !content.starts_with("https://") { warn!("invalid redirect target in {:?}: {}", path, content); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } Response::builder() .status(StatusCode::FOUND) // 302 redirect .header(header::LOCATION, HeaderValue::from_str(&content).unwrap()) .body(Body::empty()) .unwrap() }