Anarchyphase-Website/server/src/main.rs
2026-04-23 16:58:48 +02:00

283 lines
9 KiB
Rust

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<AppState>) -> 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<AppState>) -> 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<AppState>) -> impl IntoResponse {
info!("Serving index.html");
serve_file(state.website_root.join("index.html")).await
}
async fn serve_post_page(
State(state): State<AppState>,
Path(_file): Path<String>,
) -> impl IntoResponse {
info!("Serving post.html");
serve_file(state.website_root.join("post.html")).await
}
async fn serve_backdrops(State(state): State<AppState>) -> impl IntoResponse {
info!("Serving backdrops.json");
serve_file(state.website_root.join("api/backdrops.json")).await
}
async fn serve_staff(State(state): State<AppState>) -> impl IntoResponse {
info!("Serving staff.json");
serve_file(state.website_root.join("api/staff.json")).await
}
async fn serve_teams(State(state): State<AppState>) -> impl IntoResponse {
info!("Serving teams.json");
serve_file(state.website_root.join("api/teams.json")).await
}
async fn serve_posts(State(state): State<AppState>) -> impl IntoResponse {
info!("Serving posts.json");
serve_file(state.website_root.join("api/posts.json")).await
}
async fn serve_post_md(
State(state): State<AppState>,
Path(file): Path<String>,
) -> 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<AppState>) -> 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<AppState>,
Path(name): Path<String>,
) -> 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()
}