use axum::{Router, response::IntoResponse, ServiceExt, handler::HandlerWithoutStateExt};
use tower_http::services::ServeDir;
use tower_http::normalize_path::NormalizePathLayer;
use tower::Layer;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer, key_extractor::SmartIpKeyExtractor};
use axum_session::{SessionLayer, SessionStore};
use crate::app::Config;
use crate::session_manager::RustBasicSessionStore;
use crate::errors::ErrorController;
use tower_governor::GovernorError;
use std::net::SocketAddr;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use std::process::Command;
use std::time::Duration;
use tower_livereload::LiveReloadLayer;
#[derive(Clone)]
#[allow(dead_code)]
pub struct AppState {
pub db: DatabaseConnection,
pub config: Arc<Config>,
}
static EMBEDDED_PUBLIC_GET: std::sync::OnceLock<fn(&str) -> Option<rust_embed::EmbeddedFile>> = std::sync::OnceLock::new();
pub fn set_embedded_public(f: fn(&str) -> Option<rust_embed::EmbeddedFile>) {
EMBEDDED_PUBLIC_GET.set(f).ok();
}
fn guess_mime(path: &str) -> &'static str {
if path.ends_with(".js") {
"application/javascript"
} else if path.ends_with(".css") {
"text/css"
} else if path.ends_with(".html") {
"text/html"
} else if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
"image/jpeg"
} else if path.ends_with(".svg") {
"image/svg+xml"
} else if path.ends_with(".ico") {
"image/x-icon"
} else if path.ends_with(".json") {
"application/json"
} else if path.ends_with(".woff") {
"font/woff"
} else if path.ends_with(".woff2") {
"font/woff2"
} else {
"application/octet-stream"
}
}
async fn embedded_fallback_handler(uri: axum::http::Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
let file_path = if path.is_empty() { "index.html" } else { path };
let file = EMBEDDED_PUBLIC_GET.get().and_then(|f| f(file_path));
match file {
Some(content) => {
let mime = guess_mime(file_path);
axum::response::Response::builder()
.header(axum::http::header::CONTENT_TYPE, mime)
.body(axum::body::Body::from(content.data))
.unwrap()
}
None => ErrorController::not_found().await.into_response(),
}
}
pub async fn start_server(
cfg: Config,
session_store: SessionStore<RustBasicSessionStore>,
db: DatabaseConnection,
app_router: Router<AppState>
) {
kill_port_if_in_use(cfg.app_port);
unsafe {
std::env::set_var("TZ", &cfg.app_timezone);
}
let state = AppState {
db,
config: Arc::new(cfg.clone()),
};
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.period(Duration::from_millis(1000 / cfg.app_limit_request))
.burst_size(cfg.app_limit_request as u32)
.finish()
.unwrap(),
);
let app = Router::new().merge(app_router);
let app = if cfg.app_debug {
let static_files = ServeDir::new("public");
app.fallback_service(static_files.not_found_service(ErrorController::not_found.into_service()))
} else {
app.fallback(embedded_fallback_handler)
};
let app = app
.layer(axum::middleware::from_fn(crate::middleware::security_headers::security_headers_middleware))
.layer(axum::middleware::from_fn(crate::middleware::logging::logging_middleware))
.layer(GovernorLayer::new(governor_conf))
.layer(SessionLayer::new(session_store))
.with_state(state);
let app = if cfg.app_debug {
tracing::info!("🔄 Fitur Live Reload (Auto-refresh) diaktifkan.");
app.layer(LiveReloadLayer::new())
} else {
app
};
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let addr_str = format!("{}:{}", cfg.app_host, cfg.app_port);
let addr: SocketAddr = addr_str.parse().expect("Alamat server tidak valid");
tracing::info!("{} berjalan di: http://{}", cfg.app_name, addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<SocketAddr>(app)).await.unwrap();
}
fn kill_port_if_in_use(port: u16) {
#[cfg(target_os = "macos")]
{
let output = Command::new("lsof")
.arg("-t")
.arg(format!("-i:{}", port))
.output();
if let Ok(out) = output {
let pid_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !pid_str.is_empty() {
tracing::warn!("Port {} sedang digunakan oleh PID {}. Membunuh proses...", port, pid_str);
for pid in pid_str.split('\n') {
if !pid.is_empty() {
let _ = Command::new("kill")
.arg("-9")
.arg(pid)
.output();
}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("fuser")
.arg("-k")
.arg(format!("{}/tcp", port))
.output();
}
#[cfg(target_os = "windows")]
{
let output = Command::new("cmd")
.args(&["/C", &format!("netstat -ano | findstr :{}", port)])
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
let mut found = false;
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(pid) = parts.last() {
if pid.parse::<u32>().is_ok() {
tracing::warn!("Port {} sedang digunakan oleh PID {}. Membunuh proses...", port, pid);
let _ = Command::new("taskkill")
.args(&["/F", "/PID", pid])
.output();
found = true;
}
}
}
if found {
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
}
}
#[allow(dead_code)]
fn handle_governor_error(err: GovernorError) -> axum::response::Response {
match err {
GovernorError::TooManyRequests { wait_time, .. } => {
ErrorController::show(
429,
&format!("Terlalu banyak permintaan. Silakan tunggu {} detik lagi.", wait_time)
).into_response()
},
_ => ErrorController::show(500, "Terjadi kesalahan pada sistem pembatas request.").into_response(),
}
}