use crate::server::AppState;
use crate::web::language::FeatureFlags;
use crate::web::viewer::{Capability, Viewer};
use axum::{
extract::{Request, State},
http::{Method, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect, Response},
Json,
};
use std::sync::Arc;
use unic_langid::LanguageIdentifier;
const OPEN_WRITES: &[&str] = &[
"/api/shopping_list",
"/api/reload",
"/login",
"/logout",
];
const PROTECTED_READS: &[(&str, Capability)] = &[
("/new", Capability::EditRecipes),
("/edit", Capability::EditRecipes),
("/api/ws/lsp", Capability::EditRecipes),
("/api/sync", Capability::Administer),
];
const WRITES: &[(&str, Capability)] = &[
("/api/shopping_list", Capability::EditLists),
("/api/pantry", Capability::EditLists),
("/api/aisles", Capability::EditLists),
("/api/recipes", Capability::EditRecipes),
("/api/recipe_image", Capability::EditRecipes),
("/api/recipe_rename", Capability::EditRecipes),
("/api/plans", Capability::EditRecipes),
("/new", Capability::EditRecipes),
];
const RECIPE_READS: &[&str] = &[
"/directory",
"/random",
"/recipe",
"/preferences",
"/atom.xml",
"/rss.xml",
"/static",
"/api/recipes",
"/api/search",
];
const PICTURES: &[&str] = &["jpg", "jpeg", "png", "webp", "gif", "avif"];
fn under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn lookup(table: &[(&str, Capability)], path: &str) -> Option<Capability> {
table
.iter()
.find(|(prefix, _)| under(path, prefix))
.map(|&(_, capability)| capability)
}
pub fn required_capability(method: &Method, path: &str) -> Option<Capability> {
if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
lookup(PROTECTED_READS, path)
} else if OPEN_WRITES.contains(&path) {
None
} else {
Some(lookup(WRITES, path).unwrap_or(Capability::Administer))
}
}
pub fn browses_recipes(method: &Method, path: &str) -> bool {
if matches!(path, "/login" | "/logout") {
return true;
}
if !matches!(*method, Method::GET | Method::HEAD) {
return false;
}
if path == "/" {
return true;
}
if under(path, "/api/static") {
return path.rsplit_once('.').is_some_and(|(name, extension)| {
!name.ends_with('/')
&& PICTURES
.iter()
.any(|picture| extension.eq_ignore_ascii_case(picture))
});
}
RECIPE_READS.iter().any(|prefix| under(path, prefix))
}
pub async fn middleware(
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Response {
let viewer = match &state.auth {
Some(auth) => auth.viewer(request.headers()),
None => Viewer::open(),
};
let viewer = if state.recipes_only {
viewer.limited_to_recipes()
} else {
viewer
};
if viewer.recipes_only() {
if !browses_recipes(request.method(), request.uri().path()) {
return match state.auth {
Some(_) => refuse(&state.url_prefix, &request),
None => StatusCode::NOT_FOUND.into_response(),
};
}
request.extensions_mut().insert(FeatureFlags {
show_shopping_list: false,
show_pantry: false,
});
}
if let Some(capability) = required_capability(request.method(), request.uri().path()) {
if !viewer.can(capability) {
return if viewer.is_signed_in() {
forbid(&state.url_prefix, &request, viewer)
} else {
refuse(&state.url_prefix, &request)
};
}
}
request.extensions_mut().insert(viewer);
next.run(request).await
}
fn is_api(path: &str) -> bool {
under(path, "/api")
}
fn refuse(url_prefix: &str, request: &Request) -> Response {
let path = request.uri().path();
tracing::debug!(method = %request.method(), path, "refused a request without sign-in");
if is_api(path) {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "Sign in to make changes" })),
)
.into_response();
}
let back_to = request
.uri()
.path_and_query()
.map_or(path, |target| target.as_str());
Redirect::to(&format!(
"{url_prefix}/login?next={}",
urlencoding::encode(back_to)
))
.into_response()
}
fn forbid(url_prefix: &str, request: &Request, viewer: Viewer) -> Response {
let path = request.uri().path();
tracing::debug!(
method = %request.method(),
path,
role = ?viewer.role(),
"refused a request the user's role does not allow"
);
if is_api(path) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "Your role does not allow this change" })),
)
.into_response();
}
let lang = request
.extensions()
.get::<LanguageIdentifier>()
.cloned()
.unwrap_or(crate::web::language::EN_US);
let features = request
.extensions()
.get::<FeatureFlags>()
.copied()
.unwrap_or_default();
let message = crate::web::templates::Tr::new(lang.clone()).t("role-forbidden");
let page = crate::server::ui::error_page(lang, url_prefix, message, features, viewer);
(StatusCode::FORBIDDEN, page).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use Capability::*;
fn needs(method: Method, path: &str) -> Option<Capability> {
required_capability(&method, path)
}
#[test]
fn list_writes_need_edit_lists() {
for (method, path) in [
(Method::POST, "/api/shopping_list/add"),
(Method::POST, "/api/shopping_list/add_menu"),
(Method::POST, "/api/shopping_list/remove"),
(Method::POST, "/api/shopping_list/check"),
(Method::POST, "/api/shopping_list/uncheck"),
(Method::POST, "/api/shopping_list/clear"),
(Method::POST, "/api/shopping_list/compact"),
(Method::POST, "/api/shopping_list/"),
(Method::POST, "/api/pantry/add"),
(Method::PUT, "/api/pantry/dairy/milk"),
(Method::DELETE, "/api/pantry/dairy/milk"),
(Method::POST, "/api/aisles"),
(Method::POST, "/api/aisles/changes"),
(Method::PUT, "/api/aisles/raw"),
] {
assert_eq!(
needs(method.clone(), path),
Some(EditLists),
"{method} {path}"
);
}
}
#[test]
fn recipe_writes_need_edit_recipes() {
for (method, path) in [
(Method::PUT, "/api/recipes/Soup.cook"),
(Method::DELETE, "/api/recipes/Soup.cook"),
(Method::PUT, "/api/recipes/Week.menu"),
(Method::PUT, "/api/recipe_image/Soup.cook"),
(Method::DELETE, "/api/recipe_image/Soup.cook"),
(Method::POST, "/api/recipe_rename/Soup.cook"),
(Method::POST, "/api/plans/Week.menu"),
(Method::POST, "/new"),
] {
assert_eq!(
needs(method.clone(), path),
Some(EditRecipes),
"{method} {path}"
);
}
}
#[test]
fn other_writes_need_an_admin() {
for (method, path) in [
(Method::POST, "/api/sync/login"),
(Method::POST, "/api/sync/logout"),
(Method::POST, "/api/sync/cancel_login"),
(Method::PATCH, "/api/anything-added-later"),
(Method::POST, "/api/recipes-bulk"),
(Method::POST, "/api/recipe_renamex/Soup.cook"),
(Method::POST, "/api/pantryx"),
(Method::POST, "/api/aislesx"),
(Method::POST, "/edit/Soup.cook"),
(Method::POST, "/loginx"),
] {
assert_eq!(
needs(method.clone(), path),
Some(Administer),
"{method} {path}"
);
}
}
#[test]
fn open_writes_are_matched_exactly() {
for path in OPEN_WRITES {
assert_eq!(needs(Method::POST, path), None, "{path}");
}
}
#[test]
fn some_reads_need_a_capability() {
for (path, capability) in [
("/new", EditRecipes),
("/edit/Soup.cook", EditRecipes),
("/api/ws/lsp", EditRecipes),
("/api/sync/status", Administer),
] {
assert_eq!(needs(Method::GET, path), Some(capability), "{path}");
assert_eq!(needs(Method::HEAD, path), Some(capability), "{path}");
}
}
#[test]
fn plain_reads_do_not() {
for path in [
"/",
"/recipe/Soup.cook",
"/directory/Mains",
"/shopping-list",
"/pantry",
"/aisles",
"/preferences",
"/login",
"/api/recipes",
"/api/recipes/Soup.cook",
"/api/shopping_list/items",
"/api/pantry",
"/api/aisles",
"/api/aisles/raw",
"/api/aisles/uncategorized",
"/api/static/Soup.jpg",
"/newsletter",
"/editor-notes",
"/api/synchronized",
] {
assert_eq!(needs(Method::GET, path), None, "{path}");
}
}
#[test]
fn recipes_only_lets_guests_read_recipes() {
for path in [
"/",
"/directory/Mains",
"/random",
"/random/Mains",
"/recipe/Soup.cook",
"/recipe/Week.menu",
"/preferences",
"/atom.xml",
"/rss.xml",
"/static/css/output.css",
"/api/recipes",
"/api/recipes/Soup.cook",
"/api/recipes/raw/Soup.cook",
"/api/search",
"/api/static/Soup.jpg",
"/api/static/Mains/Soup.1.WEBP",
"/login",
] {
assert!(browses_recipes(&Method::GET, path), "{path}");
assert!(browses_recipes(&Method::HEAD, path), "{path}");
}
assert!(browses_recipes(&Method::POST, "/login"));
assert!(browses_recipes(&Method::POST, "/logout"));
}
#[test]
fn recipes_only_keeps_guests_from_the_rest() {
for path in [
"/shopping-list",
"/pantry",
"/edit/Soup.cook",
"/new",
"/api-docs",
"/api/shopping_list/items",
"/api/shopping_list/events",
"/api/pantry",
"/api/menus",
"/api/stats",
"/api/reload",
"/api/ws/lsp",
"/api/sync/status",
"/api/recipe_image/Soup.cook",
"/api/static/.shopping-list",
"/api/static/.shopping-checked",
"/api/static/config/pantry.conf",
"/api/static/Week.menu",
"/api/static/Soup.cook",
"/api/static/.jpg",
"/api/static/Mains/.png",
"/api/anything-added-later",
"/recipes",
"/preferences-backup",
"/api/searchx",
"/loginx",
] {
assert!(!browses_recipes(&Method::GET, path), "{path}");
}
for (method, path) in [
(Method::POST, "/api/shopping_list"),
(Method::POST, "/api/shopping_list/add"),
(Method::PUT, "/api/recipes/Soup.cook"),
(Method::DELETE, "/api/recipes/Soup.cook"),
(Method::POST, "/new"),
(Method::POST, "/recipe/Soup.cook"),
] {
assert!(!browses_recipes(&method, path), "{method} {path}");
}
}
}