use crate::server::{templates::*, AppState};
use axum::{
extract::{Extension, Host, Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Form, Router,
};
use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
use fluent_templates::Loader;
use serde::Deserialize;
use std::sync::Arc;
use unic_langid::LanguageIdentifier;
fn error_page(
lang: LanguageIdentifier,
prefix: &str,
msg: impl std::fmt::Display,
) -> axum::response::Response {
let template = ErrorTemplate {
active: String::new(),
error_message: msg.to_string(),
tr: Tr::new(lang),
prefix: prefix.to_string(),
};
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))
}
async fn recipes_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> axum::response::Response {
recipes_handler(state, None, lang).await
}
async fn recipes_directory(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> axum::response::Response {
recipes_handler(state, Some(path), lang).await
}
async fn recipes_handler(
state: Arc<AppState>,
path: Option<String>,
lang: LanguageIdentifier,
) -> axum::response::Response {
let base = &state.base_path;
let search_path = if let Some(p) = &path {
base.join(p)
} else {
base.clone()
};
let tree = match cooklang_find::build_tree(&search_path) {
Ok(tree) => tree,
Err(e) => {
tracing::error!("Failed to build recipe tree: {:?}", e);
return error_page(lang, &state.url_prefix, &e);
}
};
let mut items = Vec::new();
for (name, child) in &tree.children {
let is_dir = !child.children.is_empty();
let item_path = {
let url_path = child
.recipe
.as_ref()
.and_then(|recipe| recipe.file_name())
.unwrap_or_else(|| name.to_string());
match &path {
Some(p) => format!("{p}/{url_path}"),
None => url_path.to_string(),
}
};
let (tags, image_path, is_menu) = if let Some(ref recipe) = child.recipe {
let prefix = &state.url_prefix;
let img_path = recipe.title_image().clone().and_then(|img| {
if img.starts_with("http://") || img.starts_with("https://") {
Some(img)
} else {
let img_path = camino::Utf8Path::new(&img);
if let Ok(relative) = img_path.strip_prefix(base) {
Some(format!("{prefix}/api/static/{relative}"))
} else if !img_path.is_absolute() {
Some(format!("{prefix}/api/static/{img_path}"))
} else {
img_path
.file_name()
.map(|name| format!("{prefix}/api/static/{name}"))
}
}
});
(recipe.tags(), img_path, recipe.is_menu())
} else {
(Vec::new(), None, false)
};
items.push(RecipeItem {
name: name.to_string(),
path: item_path,
is_directory: is_dir,
count: if is_dir {
count_recipes_tree(child)
} else {
None
},
description: None,
tags,
image_path,
is_menu,
});
}
items.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
let todays_menu = if path.is_none() {
crate::server::handlers::find_todays_menu(base, &tree)
} else {
None
};
let breadcrumbs = if let Some(p) = &path {
p.split('/')
.scan(String::new(), |acc, segment| {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(segment);
Some(Breadcrumb {
name: segment.to_string(),
path: acc.clone(),
})
})
.collect()
} else {
vec![]
};
let current_name = if let Some(ref p) = path {
p.split('/').next_back().unwrap_or("Recipes").to_string()
} else {
crate::server::i18n::LOCALES.lookup(&lang, "recipes-title")
};
let template = RecipesTemplate {
active: "recipes".to_string(),
current_name,
breadcrumbs,
items,
todays_menu,
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
};
template.into_response()
}
fn count_recipes_tree(tree: &cooklang_find::RecipeTree) -> Option<usize> {
let mut count = 0;
for child in tree.children.values() {
if !child.children.is_empty() {
count += count_recipes_tree(child).unwrap_or(0);
} else {
count += 1;
}
}
Some(count)
}
#[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>,
) -> axum::response::Response {
let scale = query.scale.unwrap_or(1.0);
let recipe_path = Utf8PathBuf::from(&path);
tracing::info!(
"Looking for recipe at path: {}, extension: {:?}",
path,
recipe_path.extension()
);
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}"),
);
}
};
let actual_path = entry.path();
tracing::info!(
"Recipe path: {}, actual_path: {:?}, is_menu: {}",
path,
actual_path,
entry.is_menu()
);
if entry.is_menu() {
let prefix = state.url_prefix.clone();
return match menu_page_handler(path, scale, entry, state, lang.clone()).await {
Ok(template) => template.into_response(),
Err(e) => error_page(lang, &prefix, &e),
};
}
let recipe = match crate::util::parse_recipe_from_entry(&entry, scale) {
Ok(recipe) => recipe,
Err(e) => {
tracing::error!("Failed to parse recipe: {e}");
return error_page(
lang,
&state.url_prefix,
format!("Failed to parse recipe: {e}"),
);
}
};
let aisle_content = if let Some(path) = &state.aisle_path {
match tokio::fs::read_to_string(path).await {
Ok(content) => content,
Err(e) => {
tracing::warn!("Failed to read aisle file from {:?}: {}", path, e);
String::new()
}
}
} else {
String::new()
};
let aisle = cooklang::aisle::parse_lenient(&aisle_content)
.into_output()
.unwrap_or_default();
let tags = entry.tags();
let image_path = entry
.title_image()
.clone()
.and_then(|img_path| get_image_path(&state.base_path, &state.url_prefix, img_path));
let mut ingredients = Vec::new();
let mut cookware = Vec::new();
let mut sections = Vec::new();
let mut grouped_ingredients: std::collections::HashMap<
String,
(
cooklang::quantity::GroupedQuantity,
Vec<&cooklang::model::Ingredient>,
),
> = std::collections::HashMap::new();
for entry in recipe.group_ingredients(crate::util::PARSER.converter()) {
let ingredient = entry.ingredient;
let display_name = ingredient.display_name().to_string();
grouped_ingredients
.entry(display_name)
.and_modify(|(merged_qty, igrs)| {
merged_qty.merge(&entry.quantity, crate::util::PARSER.converter());
igrs.push(ingredient);
})
.or_insert_with(|| (entry.quantity.clone(), vec![ingredient]));
}
let mut sorted_ingredients: Vec<_> = grouped_ingredients.into_iter().collect();
sorted_ingredients.sort_by(|a, b| a.0.cmp(&b.0));
for (display_name, (quantity, ingredient_list)) in sorted_ingredients {
let first_ingredient = ingredient_list[0];
let reference_path = first_ingredient.reference.as_ref().map(|r| {
if r.components.is_empty() {
r.name.clone()
} else {
format!("{}/{}", r.components.join("/"), r.name)
}
});
let combined_note = if ingredient_list.len() > 1 {
let notes: Vec<_> = ingredient_list
.iter()
.filter_map(|i| i.note.as_ref())
.collect();
if notes.is_empty() {
None
} else {
Some(
notes
.iter()
.map(|n| n.as_str())
.collect::<Vec<_>>()
.join(", "),
)
}
} else {
first_ingredient.note.clone()
};
let (formatted_quantity, formatted_unit) = if quantity.is_empty() {
(None, None)
} else {
let quantities: Vec<_> = quantity
.iter()
.map(|q| {
let qty_str =
crate::util::format::format_quantity(q.value()).unwrap_or_default();
let unit_str = q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default();
if unit_str.is_empty() {
qty_str
} else {
format!("{} {}", qty_str, unit_str)
}
})
.collect();
(Some(quantities.join(", ")), None)
};
ingredients.push(IngredientData {
name: display_name,
quantity: formatted_quantity,
unit: formatted_unit,
note: combined_note,
reference_path,
});
}
for item in &recipe.group_cookware(crate::util::PARSER.converter()) {
cookware.push(CookwareData {
name: item.cookware.name.to_string(),
});
}
let mut total_steps = 0;
for section in &recipe.sections {
let mut section_items = Vec::new();
let mut section_ingredient_indices = std::collections::HashSet::new();
let mut cooking_mode_ingredient_indices: Vec<usize> = Vec::new();
let mut step_count = 0;
for content in §ion.content {
use cooklang::Content;
match content {
Content::Step(step) => {
let mut step_items = Vec::new();
let mut step_ingredients = Vec::new();
for item in &step.items {
use crate::server::templates::{StepIngredient, StepItem};
use cooklang::Item;
match item {
Item::Text { value } => {
let parts: Vec<&str> = value.split('\n').collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
step_items.push(StepItem::LineBreak);
}
if !part.is_empty() {
step_items.push(StepItem::Text(part.to_string()));
}
}
}
Item::Ingredient { index } => {
section_ingredient_indices.insert(*index);
cooking_mode_ingredient_indices.push(*index);
if let Some(ing) = recipe.ingredients.get(*index) {
let reference_path = ing.reference.as_ref().map(|r| {
if r.components.is_empty() {
r.name.clone()
} else {
format!("{}/{}", r.components.join("/"), r.name)
}
});
step_items.push(StepItem::Ingredient {
name: ing.name.to_string(),
reference_path,
});
step_ingredients.push(StepIngredient {
name: ing.name.to_string(),
quantity: ing.quantity.as_ref().and_then(|q| {
crate::util::format::format_quantity(q.value())
}),
unit: ing
.quantity
.as_ref()
.and_then(|q| q.unit().as_ref().map(|u| u.to_string())),
note: ing.note.clone(),
});
}
}
Item::Cookware { index } => {
if let Some(cw) = recipe.cookware.get(*index) {
step_items.push(StepItem::Cookware(cw.name.to_string()));
}
}
Item::Timer { index } => {
if let Some(timer) = recipe.timers.get(*index) {
let mut timer_text = String::new();
if let Some(quantity) = &timer.quantity {
if let Some(formatted) =
crate::util::format::format_quantity(quantity.value())
{
timer_text.push_str(&formatted);
}
if let Some(unit) = quantity.unit() {
if !timer_text.is_empty() {
timer_text.push(' ');
}
timer_text.push_str(unit);
}
}
if timer_text.is_empty() {
timer_text = "timer".to_string();
}
step_items.push(StepItem::Timer(timer_text));
}
}
Item::InlineQuantity { index } => {
if let Some(q) = recipe.inline_quantities.get(*index) {
let mut qty = crate::util::format::format_quantity(q.value())
.unwrap_or_default();
if let Some(unit) = q.unit() {
if !qty.is_empty() {
qty.push_str(&format!(" {unit}"));
} else {
qty = unit.to_string();
}
}
step_items.push(StepItem::Quantity(qty));
}
}
}
}
let section_image_path = entry
.step_images()
.get(0, total_steps + step_count + 1)
.and_then(|img_path| {
get_image_path(
&state.base_path,
&state.url_prefix,
img_path.to_string(),
)
});
section_items.push(RecipeSectionItem::Step(StepData {
number: step_count + 1,
items: step_items,
ingredients: step_ingredients,
image_path: section_image_path,
}));
step_count += 1;
}
Content::Text(text) => {
if text.trim() != "-" {
section_items.push(RecipeSectionItem::Note(text.trim().to_string()));
}
}
}
}
if !section_items.is_empty() {
use crate::server::templates::RecipeSection;
let mut section_grouped_ingredients: std::collections::HashMap<
String,
(
cooklang::quantity::GroupedQuantity,
Vec<&cooklang::model::Ingredient>,
),
> = std::collections::HashMap::new();
for idx in section_ingredient_indices {
if let Some(ingredient) = recipe.ingredients.get(idx) {
let display_name = ingredient.display_name().to_string();
let qty = if let Some(q) = &ingredient.quantity {
let mut grouped_qty = cooklang::quantity::GroupedQuantity::empty();
grouped_qty.add(q, crate::util::PARSER.converter());
grouped_qty
} else {
cooklang::quantity::GroupedQuantity::empty()
};
section_grouped_ingredients
.entry(display_name)
.and_modify(|(merged_qty, igrs)| {
if let Some(q) = &ingredient.quantity {
merged_qty.add(q, crate::util::PARSER.converter());
}
igrs.push(ingredient);
})
.or_insert_with(|| (qty, vec![ingredient]));
}
}
let mut sorted_section_ingredients: Vec<_> =
section_grouped_ingredients.into_iter().collect();
sorted_section_ingredients.sort_by(|a, b| a.0.cmp(&b.0));
let mut section_ingredients = Vec::new();
for (display_name, (quantity, ingredient_list)) in sorted_section_ingredients {
let first_ingredient = ingredient_list[0];
let reference_path = first_ingredient.reference.as_ref().map(|r| {
if r.components.is_empty() {
r.name.clone()
} else {
format!("{}/{}", r.components.join("/"), r.name)
}
});
let combined_note = if ingredient_list.len() > 1 {
let notes: Vec<_> = ingredient_list
.iter()
.filter_map(|i| i.note.as_ref())
.collect();
if notes.is_empty() {
None
} else {
Some(
notes
.iter()
.map(|n| n.as_str())
.collect::<Vec<_>>()
.join(", "),
)
}
} else {
first_ingredient.note.clone()
};
let (formatted_quantity, formatted_unit) = if quantity.is_empty() {
(None, None)
} else {
let quantities: Vec<_> = quantity
.iter()
.map(|q| {
let qty_str =
crate::util::format::format_quantity(q.value()).unwrap_or_default();
let unit_str =
q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default();
if unit_str.is_empty() {
qty_str
} else {
format!("{} {}", qty_str, unit_str)
}
})
.collect();
(Some(quantities.join(", ")), None)
};
section_ingredients.push(IngredientData {
name: display_name,
quantity: formatted_quantity,
unit: formatted_unit,
note: combined_note,
reference_path,
});
}
let mut cooking_mode_ingredients_with_key: Vec<(
Option<(usize, usize)>,
IngredientData,
)> = Vec::new();
for idx in &cooking_mode_ingredient_indices {
if let Some(ingredient) = recipe.ingredients.get(*idx) {
if !ingredient.modifiers().should_be_listed() {
continue;
}
let sort_key = aisle.ingredient_sort_key(&ingredient.name);
let (formatted_quantity, formatted_unit) = if let Some(q) = &ingredient.quantity
{
let qty_str = crate::util::format::format_quantity(q.value());
let unit_str = q.unit().as_ref().map(|u| u.to_string());
(qty_str, unit_str)
} else {
(None, None)
};
cooking_mode_ingredients_with_key.push((
sort_key,
IngredientData {
name: ingredient.name.to_string(),
quantity: formatted_quantity,
unit: formatted_unit,
note: ingredient.note.clone(),
reference_path: None,
},
));
}
}
cooking_mode_ingredients_with_key.sort_by(|a, b| match (&a.0, &b.0) {
(Some(ka), Some(kb)) => ka.cmp(kb),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.1.name.cmp(&b.1.name),
});
let cooking_mode_ingredients: Vec<IngredientData> = cooking_mode_ingredients_with_key
.into_iter()
.map(|(_, data)| data)
.collect();
sections.push(RecipeSection {
name: section.name.clone(),
items: section_items.clone(),
step_offset: total_steps,
ingredients: section_ingredients,
cooking_mode_ingredients,
});
total_steps += step_count;
}
}
let breadcrumbs: Vec<String> = path
.split('/')
.map(|s| s.trim_end_matches(".cook").to_string())
.collect();
let metadata = if recipe.metadata.map.is_empty() {
None
} else {
let get_field = |key: &str| -> Option<String> {
recipe.metadata.get(key).and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else if let Some(n) = v.as_i64() {
Some(n.to_string())
} else {
v.as_f64().map(crate::util::format::format_number)
}
})
};
let mut custom_metadata = Vec::new();
for (key, value) in recipe.metadata.map_filtered() {
if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) {
if key_str.starts_with("source.") || key_str.starts_with("time.") {
continue;
}
custom_metadata.push((key_str.to_string(), val_str.to_string()));
}
}
Some(RecipeMetadata {
servings: get_field("servings"),
time: get_field("time"),
difficulty: get_field("difficulty"),
course: get_field("course"),
prep_time: get_field("prep time")
.or_else(|| get_field("prep_time"))
.or_else(|| get_field("preptime"))
.or_else(|| get_field("time.prep")),
cook_time: get_field("cook time")
.or_else(|| get_field("cook_time"))
.or_else(|| get_field("cooktime"))
.or_else(|| get_field("time.cook")),
cuisine: get_field("cuisine"),
diet: get_field("diet"),
author: get_field("author").or_else(|| get_field("source.author")),
description: get_field("description"),
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
})
};
let recipe_name = recipe
.metadata
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
path.split('/')
.next_back()
.unwrap_or(&path)
.replace(".cook", "")
});
let template = RecipeTemplate {
active: "recipes".to_string(),
recipe: RecipeData {
name: recipe_name,
metadata,
},
recipe_path: path,
breadcrumbs,
scale,
tags,
ingredients,
cookware,
sections,
image_path,
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
};
template.into_response()
}
async fn edit_page(
Path(path): Path<String>,
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> axum::response::Response {
tracing::info!("Edit page requested for path: {}", path);
let path_check = Utf8Path::new(&path);
if !path_check
.components()
.all(|c| matches!(c, Utf8Component::Normal(_)))
{
tracing::error!("Invalid path: {path}");
return error_page(lang, &state.url_prefix, format!("Invalid path: {path}"));
}
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}"),
);
}
};
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}"),
);
}
};
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}"),
);
}
};
let recipe_name = path
.split('/')
.next_back()
.unwrap_or(&path)
.replace(".cook", "")
.replace(".menu", "");
let template = crate::server::templates::EditTemplate {
active: "recipes".to_string(),
recipe_name,
recipe_path: path,
content,
base_path: state.base_path.to_string(),
tr: crate::server::templates::Tr::new(lang),
prefix: state.url_prefix.clone(),
};
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>,
Query(query): Query<NewPageQuery>,
) -> impl askama_axum::IntoResponse {
crate::server::templates::NewTemplate {
active: "recipes".to_string(),
tr: Tr::new(lang),
error: query.error,
filename: query.filename,
prefix: state.url_prefix.clone(),
}
}
#[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) {
if let Ok(origin_str) = origin.to_str() {
if let Ok(origin_url) = url::Url::parse(origin_str) {
if let Some(origin_host) = origin_url.host_str() {
let origin_with_port = if let Some(port) = origin_url.port() {
format!("{}:{}", origin_host, port)
} else {
origin_host.to_string()
};
return origin_with_port == host || origin_host == host;
}
}
}
return false;
}
if let Some(referer) = headers.get(header::REFERER) {
if let Ok(referer_str) = referer.to_str() {
if let Ok(referer_url) = url::Url::parse(referer_str) {
if let Some(referer_host) = referer_url.host_str() {
let referer_with_port = if let Some(port) = referer_url.port() {
format!("{}:{}", referer_host, port)
} else {
referer_host.to_string()
};
return referer_with_port == host || referer_host == host;
}
}
}
return false;
}
false
}
async fn create_recipe(
State(state): State<Arc<AppState>>,
Host(host): Host,
headers: HeaderMap,
Form(form): Form<NewRecipeForm>,
) -> impl IntoResponse {
if !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()
}
fn get_image_path(base_path: &Utf8PathBuf, prefix: &str, img_path: String) -> Option<String> {
tracing::debug!("Recipe image path from entry: {}", img_path);
if img_path.starts_with("http://") || img_path.starts_with("https://") {
Some(img_path)
} else {
let img_path = camino::Utf8Path::new(&img_path);
if let Ok(relative) = img_path.strip_prefix(base_path) {
let result = format!("{prefix}/api/static/{relative}");
tracing::debug!("Image path relative to base: {}", result);
Some(result)
} else if !img_path.is_absolute() {
Some(format!("{prefix}/api/static/{img_path}"))
} else {
img_path
.file_name()
.map(|name| format!("{prefix}/api/static/{name}"))
}
}
}
async fn menu_page_handler(
path: String,
scale: f64,
entry: cooklang_find::RecipeEntry,
state: Arc<AppState>,
lang: LanguageIdentifier,
) -> Result<MenuTemplate, String> {
let recipe = crate::util::parse_recipe_from_entry(&entry, scale).map_err(|e| {
tracing::error!("Failed to parse menu: {e}");
format!("Failed to parse menu: {e}")
})?;
let image_path = entry.title_image().clone().and_then(|img_path| {
if img_path.starts_with("http://") || img_path.starts_with("https://") {
Some(img_path)
} else {
let img_path = camino::Utf8Path::new(&img_path);
let prefix = &state.url_prefix;
if let Ok(relative) = img_path.strip_prefix(&state.base_path) {
Some(format!("{prefix}/api/static/{relative}"))
} else if !img_path.is_absolute() {
Some(format!("{prefix}/api/static/{img_path}"))
} else {
img_path
.file_name()
.map(|name| format!("{prefix}/api/static/{name}"))
}
}
});
let breadcrumbs: Vec<String> = path.split('/').map(|s| s.to_string()).collect();
let mut sections = Vec::new();
for section in &recipe.sections {
let section_name = section.name.clone();
let mut lines = Vec::new();
for content in §ion.content {
use cooklang::Content;
if let Content::Step(step) = content {
let mut step_items = Vec::new();
let mut current_text = String::new();
for item in &step.items {
use crate::server::templates::MenuSectionItem;
use cooklang::Item;
match item {
Item::Text { value } => {
if value == "-" {
if !current_text.is_empty() {
step_items.push(MenuSectionItem::Text(current_text.clone()));
current_text.clear();
}
if !step_items.is_empty() {
lines.push(step_items.clone());
step_items.clear();
}
} else {
let parts: Vec<&str> = value.split('\n').collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
if !current_text.is_empty() {
step_items
.push(MenuSectionItem::Text(current_text.clone()));
current_text.clear();
}
if !step_items.is_empty() {
lines.push(step_items.clone());
step_items.clear();
}
}
if !part.is_empty() {
current_text.push_str(part);
}
}
}
}
Item::Ingredient { index } => {
if !current_text.is_empty() {
step_items.push(MenuSectionItem::Text(current_text.clone()));
current_text.clear();
}
if let Some(ing) = recipe.ingredients.get(*index) {
if let Some(ref recipe_ref) = ing.reference {
let recipe_scale =
ing.quantity.as_ref().and_then(|q| match q.value() {
cooklang::Value::Number(n) => Some(n.value()),
_ => None,
});
let final_scale = recipe_scale.map(|s| s * scale);
let name = if recipe_ref.components.is_empty() {
recipe_ref.name.clone()
} else {
format!(
"{}/{}",
recipe_ref.components.join("/"),
recipe_ref.name
)
};
step_items.push(MenuSectionItem::RecipeReference {
name,
scale: final_scale,
});
} else {
let quantity = ing.quantity.as_ref().and_then(|q| {
crate::util::format::format_quantity(q.value())
});
let unit = ing
.quantity
.as_ref()
.and_then(|q| q.unit().as_ref().map(|u| u.to_string()));
step_items.push(MenuSectionItem::Ingredient {
name: ing.name.to_string(),
quantity,
unit,
});
}
}
}
_ => {} }
}
if !current_text.is_empty() {
step_items.push(MenuSectionItem::Text(current_text));
}
if !step_items.is_empty() {
lines.push(step_items);
}
}
}
lines.retain(|line| {
!line
.iter()
.all(|item| matches!(item, MenuSectionItem::Text(t) if t.trim().is_empty()))
});
if !lines.is_empty() {
sections.push(MenuSection {
name: section_name,
lines,
});
}
}
let metadata = if recipe.metadata.map.is_empty() {
None
} else {
let get_field = |key: &str| -> Option<String> {
recipe.metadata.get(key).and_then(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else if let Some(n) = v.as_i64() {
Some(n.to_string())
} else {
v.as_f64().map(crate::util::format::format_number)
}
})
};
let mut custom_metadata = Vec::new();
for (key, value) in recipe.metadata.map_filtered() {
if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) {
custom_metadata.push((key_str.to_string(), val_str.to_string()));
}
}
Some(RecipeMetadata {
servings: get_field("servings"),
time: get_field("time"),
difficulty: get_field("difficulty"),
course: get_field("course"),
prep_time: get_field("prep time")
.or_else(|| get_field("prep_time"))
.or_else(|| get_field("preptime")),
cook_time: get_field("cook time")
.or_else(|| get_field("cook_time"))
.or_else(|| get_field("cooktime")),
cuisine: get_field("cuisine"),
diet: get_field("diet"),
author: get_field("author").or_else(|| get_field("source.author")),
description: get_field("description"),
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
})
};
let menu_name = recipe
.metadata
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
path.split('/')
.next_back()
.unwrap_or(&path)
.replace(".menu", "")
});
let template = MenuTemplate {
active: "recipes".to_string(),
name: menu_name,
recipe_path: path,
breadcrumbs,
scale,
metadata,
sections,
image_path,
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
};
Ok(template)
}
async fn shopping_list_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> impl askama_axum::IntoResponse {
ShoppingListTemplate {
active: "shopping".to_string(),
tr: Tr::new(lang),
prefix: state.url_prefix.clone(),
}
}
async fn pantry_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> Result<impl askama_axum::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::server::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::server::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(),
})
}
async fn preferences_page(
State(state): State<Arc<AppState>>,
Extension(lang): Extension<LanguageIdentifier>,
) -> impl askama_axum::IntoResponse {
#[cfg(feature = "sync")]
let (sync_logged_in, sync_email, sync_syncing) = 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(),
}
}