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, HeaderValue, Response, StatusCode},
routing::{get, post},
Router,
};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Subcommand};
#[cfg(feature = "sync")]
use std::sync::Mutex;
use std::{net::IpAddr, net::SocketAddr, sync::Arc};
use tower_http::{services::ServeDir, set_header::SetResponseHeader};
use tracing::{error, info};
mod activity;
mod aisle_file;
pub mod auth;
mod cors;
mod fs_atomic;
mod handlers;
mod lsp_bridge;
mod plan_text;
mod rename;
mod shopping_list_watcher;
mod title_image;
mod ui;
#[derive(Debug, Args)]
#[command(args_conflicts_with_subcommands = true)]
pub struct ServerArgs {
#[command(subcommand)]
command: Option<ServerCommand>,
#[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,
#[arg(long, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
users_file: Option<Utf8PathBuf>,
#[arg(long)]
recipes_only: bool,
}
#[derive(Debug, Subcommand)]
enum ServerCommand {
User(auth::cli::UserArgs),
HashPassword,
}
impl ServerArgs {
pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
self.base_path.clone()
}
}
pub fn run(ctx: Context, mut args: ServerArgs) -> Result<()> {
match args.command.take() {
Some(ServerCommand::User(user_args)) => auth::cli::run_user(user_args),
Some(ServerCommand::HashPassword) => auth::cli::run_hash_password(),
None => serve(ctx, args),
}
}
#[tokio::main]
async fn serve(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 = Arc::new(cors::CorsConfig::from_args(
&cors_origins(&args),
args.cors_allow_credentials,
)?);
let listens_beyond_localhost = args.host.is_some();
let state = build_state(ctx, args, Arc::clone(&cors))?;
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);
match &state.auth {
Some(auth) => {
let users = auth.users();
println!(
"Sign-in: on, {} user(s) in {}. Guests can browse but not make changes.",
users.len(),
auth.users_path()
);
if users.is_empty() {
println!("No users are listed, so nobody can make changes.");
}
if !state.csrf_check {
tracing::warn!(
"--no-csrf-check with sign-in on: signed-in browsers are protected from \
other sites only by their cookie's SameSite=Lax"
);
}
}
None if listens_beyond_localhost => println!(
"Sign-in: off. Anyone who can reach this server can change your recipes; \
add a user with `cook server user add <name>` to require sign-in."
),
None => {}
}
if state.recipes_only {
println!(
"Recipes only: {} only browse recipes.",
if state.auth.is_some() {
"guests"
} else {
"visitors"
}
);
}
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",
SetResponseHeader::overriding(
ServeDir::new(&state.base_path),
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
),
)
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&state),
auth::middleware::middleware,
));
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 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(
Arc::clone(&cors),
cors::write_guard,
))
} else {
app
};
let app = match cors_layer {
Some(cors_layer) => app.layer(cors_layer),
None => app,
};
let app = if csrf_check {
app.layer(axum::middleware::from_fn_with_state(cors, cors::host_guard))
} else {
app
};
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 cors_origins(args: &ServerArgs) -> Vec<String> {
if !args.cors_origin.is_empty() {
return args.cors_origin.clone();
}
std::env::var(cors::ORIGIN_ENV)
.map(|value| cors::origins_from_env(&value))
.unwrap_or_default()
}
const RECIPES_ONLY_ENV: &str = "COOK_RECIPES_ONLY";
fn recipes_only(args: &ServerArgs) -> bool {
args.recipes_only || std::env::var(RECIPES_ONLY_ENV).is_ok_and(|value| env_flag(&value))
}
fn env_flag(value: &str) -> bool {
["1", "true", "yes"]
.iter()
.any(|on| value.trim().eq_ignore_ascii_case(on))
}
fn build_state(
ctx: Context,
args: ServerArgs,
cors: Arc<cors::CorsConfig>,
) -> Result<Arc<AppState>> {
let recipes_only = recipes_only(&args);
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);
let auth = auth::setup(args.users_file.as_deref(), &absolute_path)?;
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,
cors,
auth,
recipes_only,
lsp_sessions: lsp_bridge::SessionLimit::new(args.max_lsp_sessions),
checked_log_lock: Arc::new(tokio::sync::Mutex::new(())),
aisle_lock: 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 cors: Arc<cors::CorsConfig>,
pub auth: Option<Arc<auth::Auth>>,
pub recipes_only: bool,
pub lsp_sessions: lsp_bridge::SessionLimit,
pub checked_log_lock: Arc<tokio::sync::Mutex<()>>,
pub aisle_lock: 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,
}
impl AppState {
pub fn aisle_file(&self) -> Option<Utf8PathBuf> {
self.aisle_path.clone().or_else(|| {
let local = self.local_aisle_file();
local.is_file().then_some(local)
})
}
pub fn local_aisle_file(&self) -> Utf8PathBuf {
self.base_path.join("config").join("aisle.conf")
}
}
#[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(
"/aisles",
get(handlers::get_aisles).post(handlers::create_aisles),
)
.route("/aisles/changes", post(handlers::change_aisles))
.route(
"/aisles/raw",
get(handlers::get_raw_aisles).put(handlers::put_raw_aisles),
)
.route("/aisles/uncategorized", get(handlers::get_uncategorized))
.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(
"/recipe_image/{*path}",
get(handlers::recipe_image_get)
.put(handlers::recipe_image_put)
.delete(handlers::recipe_image_delete)
.layer(DefaultBodyLimit::max(title_image::MAX_UPLOAD_BYTES)),
)
.route("/recipe_rename/{*path}", post(handlers::recipe_rename))
.route("/menus", get(handlers::list_menus))
.route("/menus/{*path}", get(handlers::get_menu))
.route("/plans/{*path}", post(handlers::plan_edit))
.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()
})
}
fn canonical_or_nearest(path: &Utf8Path) -> Utf8PathBuf {
let mut missing = Vec::new();
let mut current = path;
loop {
if let Ok(canonical) = current.canonicalize_utf8() {
return missing
.iter()
.rev()
.fold(canonical, |acc: Utf8PathBuf, part| acc.join(part));
}
match (current.parent(), current.file_name()) {
(Some(parent), Some(name)) => {
missing.push(name);
current = parent;
}
_ => return path.to_path_buf(),
}
}
}
#[cfg(test)]
mod tests {
use crate::args::{CliArgs, Command};
use clap::{CommandFactory, Parser};
#[test]
fn cli_definition_is_valid() {
CliArgs::command().debug_assert();
}
#[test]
fn recipes_only_env_values() {
for value in ["1", "true", "TRUE", "yes", " Yes "] {
assert!(super::env_flag(value), "{value:?}");
}
for value in ["", "0", "false", "no", "off", "2"] {
assert!(!super::env_flag(value), "{value:?}");
}
}
#[test]
fn subcommands_and_recipe_directory_are_told_apart() {
let parse = |args: &[&str]| match CliArgs::try_parse_from(args).unwrap().command {
Command::Server(server) => server,
_ => panic!("not the server command"),
};
let serve = parse(&["cook", "server", "./recipes"]);
assert!(serve.command.is_none());
assert_eq!(
serve.base_path.as_deref().map(|p| p.as_str()),
Some("./recipes")
);
assert!(matches!(
parse(&["cook", "server", "user", "add", "alice"]).command,
Some(super::ServerCommand::User(_))
));
assert!(matches!(
parse(&["cook", "server", "hash-password"]).command,
Some(super::ServerCommand::HashPassword)
));
}
}