use crate::server::AppState;
use crate::web::language::FeatureFlags;
use crate::web::templates::*;
use axum::{
extract::{Extension, Path, Query, State},
http::{header, HeaderMap, StatusCode, Uri},
response::IntoResponse,
routing::get,
Form, Router,
};
use camino::Utf8PathBuf;
use serde::Deserialize;
use std::sync::Arc;
use unic_langid::LanguageIdentifier;
fn error_page(
lang: LanguageIdentifier,
prefix: &str,
msg: impl std::fmt::Display,
features: FeatureFlags,
) -> axum::response::Response {
let template = ErrorTemplate {
active: String::new(),
error_message: msg.to_string(),
tr: Tr::new(lang),
prefix: prefix.to_string(),
static_mode: false,
repo_url: None,
features,
};
template.into_response()
}
pub fn ui() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(recipes_page))
.route("/directory/{*path}", get(recipes_directory))
.route("/recipe/{*path}", get(recipe_page))
.route("/edit/{*path}", get(edit_page))
.route("/new", get(new_page).post(create_recipe))
.route("/shopping-list", get(shopping_list_page))
.route("/pantry", get(pantry_page))
.route("/preferences", get(preferences_page))
.route("/api-docs", get(api_docs_page))
.route("/atom.xml", get(atom_feed))
.route("/rss.xml", get(rss_feed))
}
async fn recipes_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> axum::response::Response {
recipes_handler(state, None, lang, features).await
}
async fn recipes_directory(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> axum::response::Response {
recipes_handler(state, Some(path), lang, features).await
}
async fn recipes_handler(
state: Arc<AppState>,
path: Option<String>,
lang: LanguageIdentifier,
features: FeatureFlags,
) -> axum::response::Response {
let input = crate::web::builders::RecipesBuildInput {
base_path: &state.base_path,
url_prefix: &state.url_prefix,
sub_path: path.as_deref(),
lang: lang.clone(),
static_mode: false,
repo_url: None,
features,
};
match crate::web::builders::build_recipes_template(input) {
Ok(template) => template.into_response(),
Err(e) => {
tracing::error!("Failed to build recipes template: {:?}", e);
error_page(lang, &state.url_prefix, &e, features)
}
}
}
#[derive(Deserialize)]
struct RecipeQuery {
scale: Option<f64>,
}
async fn recipe_page(
Path(path): Path<String>,
Query(query): Query<RecipeQuery>,
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> axum::response::Response {
let scale = query.scale.unwrap_or(1.0);
let input = crate::web::builders::RecipeBuildInput {
base_path: &state.base_path,
url_prefix: &state.url_prefix,
recipe_path: &path,
aisle_path: state.aisle_path.as_ref(),
scale,
lang: lang.clone(),
static_mode: false,
repo_url: None,
features,
};
match crate::web::builders::build_recipe_template(input) {
Ok(crate::web::builders::RecipeBuildOutput::Recipe(template)) => template.into_response(),
Ok(crate::web::builders::RecipeBuildOutput::Menu(template)) => template.into_response(),
Err(e) => {
tracing::error!("Failed to build recipe template: {:?}", e);
error_page(lang, &state.url_prefix, &e, features)
}
}
}
async fn edit_page(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> axum::response::Response {
tracing::info!("Edit page requested for path: {}", path);
if !crate::util::is_safe_relative_path(&path) {
tracing::error!("Invalid path: {path}");
return error_page(
lang,
&state.url_prefix,
format!("Invalid path: {path}"),
features,
);
}
let recipe_path = Utf8PathBuf::from(&path);
let entry = match cooklang_find::get_recipe(vec![&state.base_path], &recipe_path) {
Ok(entry) => entry,
Err(e) => {
tracing::error!("Recipe not found: {path}");
return error_page(
lang,
&state.url_prefix,
format!("Recipe not found: {path}: {e}"),
features,
);
}
};
let file_path = match entry.path() {
Some(p) => p,
None => {
tracing::error!("Recipe has no file path: {path}");
return error_page(
lang,
&state.url_prefix,
format!("Recipe has no file path: {path}"),
features,
);
}
};
let content = match tokio::fs::read_to_string(file_path).await {
Ok(content) => content,
Err(e) => {
tracing::error!("Failed to read recipe file: {e}");
return error_page(
lang,
&state.url_prefix,
format!("Failed to read recipe file: {e}"),
features,
);
}
};
let recipe_name = path
.split('/')
.next_back()
.unwrap_or(&path)
.replace(".cook", "")
.replace(".menu", "");
let template = crate::web::templates::EditTemplate {
active: "recipes".to_string(),
recipe_name,
recipe_path: path,
content,
base_path: state.base_path.to_string(),
tr: crate::web::templates::Tr::new(lang),
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
};
template.into_response()
}
#[derive(Deserialize, Default)]
struct NewPageQuery {
error: Option<String>,
filename: Option<String>,
}
async fn new_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
Query(query): Query<NewPageQuery>,
) -> impl IntoResponse {
crate::web::templates::NewTemplate {
active: "recipes".to_string(),
tr: Tr::new(lang),
error: query.error,
filename: query.filename,
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
}
}
#[derive(Deserialize)]
struct NewRecipeForm {
filename: String,
}
fn new_page_error(prefix: &str, error: &str, filename: &str) -> axum::response::Response {
let encoded_error = urlencoding::encode(error);
let encoded_filename = urlencoding::encode(filename);
axum::response::Redirect::to(&format!(
"{prefix}/new?error={}&filename={}",
encoded_error, encoded_filename
))
.into_response()
}
fn validate_same_origin(headers: &HeaderMap, host: &str) -> bool {
if let Some(origin) = headers.get(header::ORIGIN) {
return origin
.to_str()
.is_ok_and(|origin| super::cors::origin_matches_host(origin, host));
}
if let Some(referer) = headers.get(header::REFERER) {
return referer
.to_str()
.is_ok_and(|referer| super::cors::origin_matches_host(referer, host));
}
false
}
async fn create_recipe(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Form(form): Form<NewRecipeForm>,
) -> impl IntoResponse {
let host = super::cors::host_header(&headers).unwrap_or_default();
if state.csrf_check && !validate_same_origin(&headers, host) {
tracing::warn!("CSRF validation failed for create_recipe request");
return (StatusCode::FORBIDDEN, "Invalid request origin").into_response();
}
let original_filename = form.filename.clone();
if form.filename.trim().is_empty() {
return new_page_error(
&state.url_prefix,
"Recipe name cannot be empty",
&original_filename,
);
}
let recipe_path: String = form
.filename
.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '/')
.collect();
let recipe_path = recipe_path
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("/");
if recipe_path.is_empty() {
return new_page_error(
&state.url_prefix,
"Recipe name cannot be empty",
&original_filename,
);
}
let file_path = state.base_path.join(format!("{}.cook", recipe_path));
let base_path_clone = state.base_path.clone();
let base_canonical =
match tokio::task::spawn_blocking(move || base_path_clone.canonicalize_utf8()).await {
Ok(Ok(p)) => p,
_ => {
return new_page_error(
&state.url_prefix,
"Internal error: invalid base path",
&original_filename,
);
}
};
let normalized_path = file_path.as_str().replace("\\", "/");
if normalized_path.contains("/../") || normalized_path.ends_with("/..") {
tracing::warn!("Path traversal attempt detected in: {}", recipe_path);
return new_page_error(&state.url_prefix, "Invalid recipe path", &original_filename);
}
if let Some(parent) = file_path.parent() {
if !parent.exists() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
tracing::error!("Failed to create directories: {}", e);
return new_page_error(&state.url_prefix, "Failed to create directory. Check that the recipes folder has write permissions.", &original_filename);
}
}
let parent_owned = parent.to_owned();
match tokio::task::spawn_blocking(move || parent_owned.canonicalize_utf8()).await {
Ok(Ok(parent_canonical)) => {
if !parent_canonical.starts_with(&base_canonical) {
tracing::warn!(
"Path traversal attempt: {} not under {}",
parent_canonical,
base_canonical
);
let _ = tokio::fs::remove_dir_all(parent).await;
return new_page_error(
&state.url_prefix,
"Invalid recipe path",
&original_filename,
);
}
}
_ => {
return new_page_error(
&state.url_prefix,
"Invalid recipe path",
&original_filename,
);
}
}
}
let recipe_name = recipe_path
.split('/')
.next_back()
.unwrap_or(&recipe_path)
.replace(['-', '_'], " ");
let template = format!("---\ntitle: {}\n---\n\n", recipe_name);
use tokio::io::AsyncWriteExt;
let file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true) .open(&file_path)
.await;
match file {
Ok(mut f) => {
if let Err(e) = f.write_all(template.as_bytes()).await {
tracing::error!("Failed to write recipe: {}", e);
return new_page_error(
&state.url_prefix,
"Failed to write recipe file",
&original_filename,
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return new_page_error(
&state.url_prefix,
"A recipe with this name already exists",
&original_filename,
);
}
Err(e) => {
tracing::error!("Failed to create recipe file: {}", e);
return new_page_error(
&state.url_prefix,
"Failed to create recipe file",
&original_filename,
);
}
}
axum::response::Redirect::to(&format!("{}/edit/{}.cook", state.url_prefix, recipe_path))
.into_response()
}
async fn shopping_list_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> impl IntoResponse {
ShoppingListTemplate {
active: "shopping".to_string(),
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
}
}
async fn pantry_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> Result<impl IntoResponse, StatusCode> {
let pantry_path = state.pantry_path.as_ref();
let mut sections = Vec::new();
if let Some(path) = pantry_path {
if let Ok(content) = tokio::fs::read_to_string(path).await {
let result = cooklang::pantry::parse_lenient(&content);
if let Some(pantry_conf) = result.output() {
for (section_name, items) in &pantry_conf.sections {
let mut pantry_items = Vec::new();
for item in items {
pantry_items.push(crate::web::templates::PantryItem {
name: item.name().to_string(),
quantity: item.quantity().map(|q| q.to_string()),
bought: item.bought().map(|b| b.to_string()),
expire: item.expire().map(|e| e.to_string()),
low: item.low().map(|l| l.to_string()),
});
}
sections.push(crate::web::templates::PantrySection {
name: section_name.clone(),
items: pantry_items,
});
}
}
}
}
Ok(PantryTemplate {
active: "pantry".to_string(),
configured: pantry_path.is_some(),
sections,
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
})
}
async fn preferences_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> impl IntoResponse {
#[cfg(feature = "sync")]
let (sync_logged_in, sync_email, sync_syncing, _sync_reason) = state.sync_status().await;
#[cfg(not(feature = "sync"))]
let (sync_logged_in, sync_email, sync_syncing) = (false, None, false);
PreferencesTemplate {
active: "preferences".to_string(),
aisle_path: state
.aisle_path
.as_ref()
.map(|p| p.to_string())
.unwrap_or_else(|| "Not configured".to_string()),
pantry_path: state
.pantry_path
.as_ref()
.map(|p| p.to_string())
.unwrap_or_else(|| "Not configured".to_string()),
base_path: state.base_path.to_string(),
version: format!("{} - in food we trust", env!("CARGO_PKG_VERSION")),
tr: Tr::new(lang),
sync_enabled: cfg!(feature = "sync"),
sync_logged_in,
sync_email,
sync_syncing,
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
}
}
async fn atom_feed(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
uri: Uri,
Extension(lang): Extension<LanguageIdentifier>,
) -> axum::response::Response {
feed_response(
crate::build::feed::FeedFormat::Atom,
&state,
&headers,
&uri,
lang,
)
.await
}
async fn rss_feed(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
uri: Uri,
Extension(lang): Extension<LanguageIdentifier>,
) -> axum::response::Response {
feed_response(
crate::build::feed::FeedFormat::Rss,
&state,
&headers,
&uri,
lang,
)
.await
}
fn feed_base_url(headers: &HeaderMap, uri: &Uri, url_prefix: &str) -> Option<String> {
let host = super::cors::request_authority(headers, uri)?;
let https = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.is_some_and(|v| v.trim().eq_ignore_ascii_case("https"));
let scheme = if https { "https" } else { "http" };
let base = format!("{scheme}://{host}{url_prefix}/");
let parsed = url::Url::parse(&base).ok()?;
let clean = parsed.host().is_some()
&& parsed.username().is_empty()
&& parsed.query().is_none()
&& parsed.fragment().is_none()
&& parsed.path() == format!("{url_prefix}/");
clean.then_some(base)
}
async fn feed_response(
format: crate::build::feed::FeedFormat,
state: &AppState,
headers: &HeaderMap,
uri: &Uri,
lang: LanguageIdentifier,
) -> axum::response::Response {
use fluent_templates::Loader;
let Some(base) = feed_base_url(headers, uri, &state.url_prefix) else {
return (StatusCode::BAD_REQUEST, "Missing or invalid Host header").into_response();
};
let title = crate::web::i18n::LOCALES.lookup(&lang, "recipes-title");
let lang_tag = lang.to_string();
let base_path = state.base_path.clone();
let rendered = tokio::task::spawn_blocking(move || {
let tree = cooklang_find::build_tree(&base_path)
.map_err(|e| anyhow::anyhow!("Failed to build recipe tree: {e}"))?;
Ok::<_, anyhow::Error>(crate::build::feed::render_feed(
format,
&tree,
crate::build::feed::PageUrls::Server,
&base,
&title,
&lang_tag,
))
})
.await;
match rendered {
Ok(Ok(xml)) => ([(header::CONTENT_TYPE, format.content_type())], xml).into_response(),
Ok(Err(e)) => {
tracing::error!("Failed to render feed: {e:#}");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to render feed").into_response()
}
Err(e) => {
tracing::error!("Feed task failed: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to render feed").into_response()
}
}
}
async fn api_docs_page(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
uri: Uri,
Extension(lang): Extension<LanguageIdentifier>,
Extension(features): Extension<FeatureFlags>,
) -> impl IntoResponse {
let base_url = match super::cors::request_authority(&headers, &uri) {
Some(host) => format!("http://{host}{}/api", state.url_prefix),
None => format!("{}/api", state.url_prefix),
};
ApiDocsTemplate {
active: "preferences".to_string(),
base_url,
preamble: crate::web::api_docs::preamble(),
sections: crate::web::api_docs::sections(),
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
static_mode: false,
repo_url: None,
features,
}
}
#[cfg(test)]
mod feed_url_tests {
use super::feed_base_url;
use axum::http::{HeaderMap, HeaderValue, Uri};
fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.append(*name, HeaderValue::from_static(value));
}
map
}
fn uri() -> Uri {
"/atom.xml".parse().unwrap()
}
#[test]
fn uses_host_and_prefix() {
let h = headers(&[("host", "cook.lan:9080")]);
assert_eq!(
feed_base_url(&h, &uri(), "").as_deref(),
Some("http://cook.lan:9080/")
);
assert_eq!(
feed_base_url(&h, &uri(), "/cook").as_deref(),
Some("http://cook.lan:9080/cook/")
);
}
#[test]
fn honours_forwarded_proto_but_not_forwarded_host() {
let h = headers(&[
("host", "cook.example.com"),
("x-forwarded-proto", "https"),
("x-forwarded-host", "evil.test"),
]);
assert_eq!(
feed_base_url(&h, &uri(), "").as_deref(),
Some("https://cook.example.com/")
);
}
#[test]
fn rejects_missing_or_smuggling_hosts() {
assert_eq!(feed_base_url(&headers(&[]), &uri(), ""), None);
for bad in [
"evil.test/path",
"user@evil.test",
"evil.test?x=1",
"evil.test#x",
] {
let h = headers(&[("host", bad)]);
assert_eq!(feed_base_url(&h, &uri(), ""), None, "{bad}");
}
}
}