use crate::util::resolve_to_absolute_path;
use crate::Context;
use anyhow::{bail, Context as _, Result};
use axum::{
body::Body,
extract::{DefaultBodyLimit, Path},
http::{header, Response, StatusCode},
routing::{get, post},
Router,
};
use camino::Utf8PathBuf;
use clap::Args;
#[cfg(feature = "sync")]
use std::sync::Mutex;
use std::{net::IpAddr, net::SocketAddr, sync::Arc};
use tower_http::services::ServeDir;
use tracing::{error, info};
mod cors;
mod fs_atomic;
mod handlers;
mod lsp_bridge;
mod shopping_list_watcher;
mod ui;
#[derive(Debug, Args)]
pub struct ServerArgs {
#[arg(value_hint = clap::ValueHint::DirPath)]
base_path: Option<Utf8PathBuf>,
#[arg(long, num_args = 0..=1, value_name = "ADDRESS")]
host: Option<Option<IpAddr>>,
#[arg(short = 'p', long, default_value_t = 9080)]
port: u16,
#[arg(long)]
url_prefix: Option<String>,
#[arg(long, default_value_t = false)]
open: bool,
#[arg(long, value_name = "ORIGIN")]
cors_origin: Vec<String>,
#[arg(long, default_value_t = false)]
cors_allow_credentials: bool,
#[arg(long = "no-csrf-check", alias = "no-cors", action = clap::ArgAction::SetFalse)]
csrf_check: bool,
#[arg(long, value_name = "N", default_value_t = lsp_bridge::DEFAULT_MAX_SESSIONS)]
max_lsp_sessions: u16,
}
impl ServerArgs {
pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
self.base_path.clone()
}
}
#[tokio::main]
pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> {
let addr = match args.host {
Some(Some(addr)) => addr,
Some(None) => "::".parse()?,
None => [127, 0, 0, 1].into(),
};
let addr = SocketAddr::from((addr, args.port));
let open = args.open;
let cors = cors::CorsConfig::from_args(&args.cors_origin, args.cors_allow_credentials)?;
let state = build_state(ctx, args)?;
if state.url_prefix.is_empty() {
println!("Listening on http://{addr}");
} else {
println!("Listening on http://{addr}{}", state.url_prefix);
}
if open {
let url = format!("http://{addr}{}", state.url_prefix);
println!("Serving Web UI on {url}");
tokio::task::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
if let Err(e) = open::that(url) {
tracing::error!("Could not open the web browser: {e}");
}
});
}
#[cfg(feature = "sync")]
{
let session_guard = state.sync_session.lock().unwrap();
if let Some(ref session) = *session_guard {
match crate::sync::sync_db_path() {
Ok(db_path) => {
match crate::sync::start_sync(session, state.base_path.to_string(), db_path) {
Ok(handle) => {
*state.last_sync_reason.lock().unwrap() = None;
if let Ok(mut guard) = state.sync_handle.try_lock() {
*guard = Some(handle);
}
tracing::info!("Sync started on server boot");
}
Err(e) => tracing::warn!("Failed to start sync: {e}"),
}
}
Err(e) => tracing::warn!("Failed to resolve sync DB path: {e}"),
}
}
crate::sync::runner::start_token_refresh(
Arc::clone(&state.sync_session),
state.session_path.clone(),
state.shutdown_token.child_token(),
);
}
println!("Serving recipe files from: {:?}", state.base_path);
const MAX_BODY_SIZE: usize = 1024 * 1024;
let inner = Router::new()
.nest("/api", api(&state)?)
.merge(ui::ui())
.route("/static/{*file}", get(serve_static))
.nest_service("/api/static", ServeDir::new(&state.base_path));
let app = if state.url_prefix.is_empty() {
inner
} else {
Router::new().nest(&state.url_prefix, inner)
};
let url_prefix_for_features = state.url_prefix.clone();
let csrf_check = state.csrf_check;
#[cfg(feature = "sync")]
let state_for_shutdown = state.clone();
let cors_layer = cors.layer();
let cors = Arc::new(cors);
let app = app
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
.layer(axum::middleware::from_fn_with_state(
url_prefix_for_features,
crate::web::language::features_middleware,
))
.layer(axum::middleware::from_fn(
crate::web::language::language_middleware,
));
let app = if csrf_check {
app.layer(axum::middleware::from_fn_with_state(
cors,
cors::write_guard,
))
} else {
app
};
let app = app.layer(cors_layer);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => listener,
Err(e) => {
if e.kind() == std::io::ErrorKind::AddrInUse {
error!("Port {} is already in use. Please stop the existing server or use a different port with --port", addr.port());
return Err(anyhow::anyhow!("Port {} is already in use", addr.port()));
} else {
error!("Failed to bind to {}: {}", addr, e);
return Err(anyhow::anyhow!("Failed to bind to {}: {}", addr, e));
}
}
};
let app = tower_http::normalize_path::NormalizePath::trim_trailing_slash(app);
axum::serve(
listener,
axum::ServiceExt::<axum::extract::Request>::into_make_service(app),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("Server error")?;
#[cfg(feature = "sync")]
{
state_for_shutdown.shutdown_token.cancel();
if let Some(handle) = state_for_shutdown.sync_handle.lock().await.take() {
handle.stop().await;
}
}
info!("Server stopped");
Ok(())
}
fn build_state(ctx: Context, args: ServerArgs) -> Result<Arc<AppState>> {
let base_path = ctx.base_path().to_path_buf();
let path = args.base_path.as_ref().unwrap_or(&base_path);
let absolute_path = resolve_to_absolute_path(path)?;
if absolute_path.is_file() {
bail!("Base path {} is not a directory", absolute_path);
}
tracing::info!("Using absolute base path: {:?}", absolute_path);
let url_prefix = match args.url_prefix {
Some(prefix) => {
let prefix = prefix.trim_end_matches('/').to_string();
if !prefix.starts_with('/') {
bail!("URL prefix must start with '/', got: {prefix}");
}
if !prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
{
bail!("URL prefix contains invalid characters: {prefix}");
}
prefix
}
None => String::new(),
};
let server_ctx = Context::discover(absolute_path.clone());
let aisle_path = server_ctx.aisle().path().map(camino::Utf8Path::to_path_buf);
let pantry_path = server_ctx
.pantry()
.path()
.map(camino::Utf8Path::to_path_buf);
tracing::info!("Aisle configuration: {:?}", aisle_path);
tracing::info!("Pantry configuration: {:?}", pantry_path);
let shopping_list_events = match shopping_list_watcher::spawn(absolute_path.clone()) {
Ok(tx) => Some(tx),
Err(e) => {
tracing::warn!("Failed to start shopping list watcher; live updates disabled: {e:#}");
None
}
};
#[cfg(feature = "sync")]
let (session_path, session) = {
let path = cookcli_core::global_config_path("session.json")
.map(|p: Utf8PathBuf| p.into_std_path_buf())
.unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json"));
let session = match crate::sync::SyncSession::load(&path) {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to load sync session: {e}");
None
}
};
(path, session)
};
Ok(Arc::new(AppState {
base_path: absolute_path,
aisle_path,
pantry_path,
url_prefix,
csrf_check: args.csrf_check,
lsp_sessions: lsp_bridge::SessionLimit::new(args.max_lsp_sessions),
checked_log_lock: Arc::new(tokio::sync::Mutex::new(())),
shopping_list_events,
#[cfg(feature = "sync")]
sync_session: Arc::new(Mutex::new(session)),
#[cfg(feature = "sync")]
sync_handle: Arc::new(tokio::sync::Mutex::new(None)),
#[cfg(feature = "sync")]
last_sync_reason: Arc::new(Mutex::new(None)),
#[cfg(feature = "sync")]
pending_device_flow: Arc::new(tokio::sync::Mutex::new(None)),
#[cfg(feature = "sync")]
session_path,
#[cfg(feature = "sync")]
shutdown_token: tokio_util::sync::CancellationToken::new(),
}))
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
};
info!("Stopping server");
}
pub struct AppState {
pub base_path: Utf8PathBuf,
pub aisle_path: Option<Utf8PathBuf>,
pub pantry_path: Option<Utf8PathBuf>,
pub url_prefix: String,
pub csrf_check: bool,
pub lsp_sessions: lsp_bridge::SessionLimit,
pub checked_log_lock: Arc<tokio::sync::Mutex<()>>,
pub shopping_list_events: Option<shopping_list_watcher::ChangeSender>,
#[cfg(feature = "sync")]
pub sync_session: Arc<Mutex<Option<crate::sync::SyncSession>>>,
#[cfg(feature = "sync")]
pub sync_handle: Arc<tokio::sync::Mutex<Option<crate::sync::SyncHandle>>>,
#[cfg(feature = "sync")]
pub last_sync_reason: Arc<Mutex<Option<crate::sync::SyncFailureReason>>>,
#[cfg(feature = "sync")]
pub pending_device_flow: Arc<tokio::sync::Mutex<Option<crate::sync::PendingDeviceFlow>>>,
#[cfg(feature = "sync")]
pub session_path: std::path::PathBuf,
#[cfg(feature = "sync")]
pub shutdown_token: tokio_util::sync::CancellationToken,
}
#[cfg(feature = "sync")]
impl AppState {
pub async fn sync_status(&self) -> (bool, Option<String>, bool, Option<String>) {
let (logged_in, email) = {
let session = self.sync_session.lock().unwrap();
(
session.is_some(),
session.as_ref().and_then(|s| s.email.clone()),
)
};
let syncing = {
let mut guard = self.sync_handle.lock().await;
match guard.as_ref() {
Some(handle) if handle.is_running() => true,
Some(handle) => {
if let Some(reason) = handle.last_error_reason() {
*self.last_sync_reason.lock().unwrap() = Some(reason);
}
guard.take();
false
}
None => false,
}
};
let reason = if syncing {
None
} else {
self.last_sync_reason
.lock()
.unwrap()
.map(|r| r.as_str().to_string())
};
(logged_in, email, syncing, reason)
}
}
fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
let router = Router::new()
.route("/shopping_list", post(handlers::shopping_list))
.route(
"/shopping_list/items",
get(handlers::get_shopping_list_items),
)
.route("/shopping_list/add", post(handlers::add_to_shopping_list))
.route(
"/shopping_list/add_menu",
post(handlers::add_menu_to_shopping_list),
)
.route(
"/shopping_list/remove",
post(handlers::remove_from_shopping_list),
)
.route("/shopping_list/clear", post(handlers::clear_shopping_list))
.route("/shopping_list/check", post(handlers::check_shopping_item))
.route(
"/shopping_list/uncheck",
post(handlers::uncheck_shopping_item),
)
.route("/shopping_list/checked", get(handlers::get_checked_items))
.route("/shopping_list/compact", post(handlers::compact_checked))
.route("/shopping_list/events", get(handlers::shopping_list_events))
.route("/pantry", get(handlers::get_pantry))
.route("/pantry/add", post(handlers::add_pantry_item))
.route("/pantry/expiring", get(handlers::get_expiring))
.route("/pantry/depleted", get(handlers::get_depleted))
.route(
"/pantry/{section}/{name}",
axum::routing::delete(handlers::remove_pantry_item),
)
.route(
"/pantry/{section}/{name}",
axum::routing::put(handlers::update_pantry_item),
)
.route("/recipes", get(handlers::all_recipes))
.route("/recipes/raw/{*path}", get(handlers::recipe_raw)) .route(
"/recipes/{*path}",
get(handlers::recipe)
.put(handlers::recipe_save)
.delete(handlers::recipe_delete),
)
.route("/menus", get(handlers::list_menus))
.route("/menus/{*path}", get(handlers::get_menu))
.route("/search", get(handlers::search))
.route("/stats", get(handlers::stats))
.route("/reload", get(handlers::reload).post(handlers::reload))
.route("/ws/lsp", get(lsp_bridge::lsp_websocket));
#[cfg(feature = "sync")]
let router = router
.route("/sync/status", get(handlers::sync_status))
.route("/sync/login", post(handlers::sync_login))
.route("/sync/cancel_login", post(handlers::sync_cancel_login))
.route("/sync/logout", post(handlers::sync_logout));
Ok(router)
}
async fn serve_static(Path(path): Path<String>) -> impl axum::response::IntoResponse {
let path = path.trim_start_matches('/');
crate::web::StaticFiles::get(path)
.map(|content| {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(content.data))
.unwrap()
})
.unwrap_or_else(|| {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("404 Not Found"))
.unwrap()
})
}