mod handle_bulk;
mod handle_crud;
mod handle_list;
mod handle_password;
use crate::auth::session::CurrentUser;
use crate::context::template::{AppError, Request};
use crate::errors::error::ErrorContext;
use crate::utils::{
aliases::{AppResult, StrMap},
constante::admin_context::{common as ctx_common, list as ctx_list, permission as ctx_perm},
session_key::session::CSRF_TOKEN_KEY,
trad::{current_lang, t},
};
use crate::{
admin::{
AdminRegistry,
config::AdminConfig,
helper::resource_entry::{ResourceEntry, SortDir},
trad::{inject_admin_prefix, insert_admin_messages},
},
utils::admin_context::list::{PAGE, SORT_BY, SORT_DIR},
};
use axum::{
Extension,
extract::{Path, Query},
http::StatusCode,
response::{IntoResponse, Redirect, Response},
};
use std::{collections::HashMap, sync::Arc};
use subtle::ConstantTimeEq;
use self::handle_bulk::handle_bulk_action;
use self::handle_crud::{
handle_create_get, handle_create_post, handle_delete_get, handle_delete_post, handle_detail,
handle_edit_get, handle_edit_post,
};
use self::handle_list::{ListQuery, handle_list};
use self::handle_password::handle_reset_password;
pub(crate) fn format_datetime(value: &mut serde_json::Value) {
use chrono::NaiveDateTime;
match value {
serde_json::Value::String(s) => {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f"))
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S"))
{
*s = dt.format("%d/%m/%Y %H:%M").to_string();
}
}
serde_json::Value::Object(map) => {
for v in map.values_mut() {
format_datetime(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
format_datetime(v);
}
}
_ => {}
}
}
#[derive(Clone)]
pub struct PrototypeAdminState {
pub registry: Arc<AdminRegistry>,
pub config: Arc<AdminConfig>,
}
pub async fn admin_get(
Path((resource_key, action)): Path<(String, String)>,
Extension(state): Extension<Arc<PrototypeAdminState>>,
Extension(current_user): Extension<CurrentUser>,
Query(params): Query<StrMap>,
headers: axum::http::HeaderMap,
mut req: Request,
) -> AppResult<Response> {
let entry = state
.registry
.get(&resource_key)
.ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Resource not found"))))?;
inject_context(&mut req, &state, entry, ¤t_user);
match action.as_str() {
"list" => {
let can_access = current_user.can_access_resource(&resource_key);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
user = %current_user.username,
can_access,
"list access check"
);
}
if !can_access {
return Ok(permission_denied_dashboard(&req.notices, &state.config.prefix).await);
}
let page = params
.get(PAGE)
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(1)
.max(1);
let sort_by = params.get(SORT_BY).filter(|s| !s.is_empty()).cloned();
let sort_dir = match params.get(SORT_DIR).map(String::as_str) {
Some("desc") => SortDir::Desc,
_ => SortDir::Asc,
};
let search = params.get("search").filter(|s| !s.is_empty()).cloned();
let column_filters: Vec<(String, String)> = params
.iter()
.filter_map(|(k, v)| {
k.strip_prefix("filter_")
.filter(|_| !v.is_empty())
.map(|col| (col.to_string(), v.clone()))
})
.collect();
let filter_pages: HashMap<String, u64> = params
.iter()
.filter_map(|(k, v)| {
let col = k.strip_prefix("fp_")?;
let page = v.parse::<u64>().ok()?;
Some((col.to_string(), page))
})
.collect();
let query = ListQuery {
page,
sort_by,
sort_dir,
search,
column_filters,
filter_pages,
};
let is_htmx = headers.contains_key("hx-request");
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list)
{
crate::runique_log!(
level,
resource = %resource_key,
page = query.page,
search = ?query.search,
filters = query.column_filters.len(),
htmx = is_htmx,
"list"
);
}
handle_list(&mut req, entry, &state, query, ¤t_user, is_htmx).await
}
"create" => {
let can_access = current_user.can_access_resource(&resource_key);
let can_create = check_can_create(¤t_user, &resource_key);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
user = %current_user.username,
can_access,
can_create,
"create access check"
);
}
if !can_access {
return Ok(permission_denied_dashboard(&req.notices, &state.config.prefix).await);
}
if !can_create {
return Ok(
permission_denied(&req.notices, &state.config.prefix, &resource_key).await,
);
}
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, action = "create GET", "crud");
}
handle_create_get(&mut req, entry, &state).await
}
"bulk" => {
let can_update = check_can_update(¤t_user, &resource_key);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
user = %current_user.username,
can_update,
"bulk edit access check"
);
}
if !can_update {
return Ok(
permission_denied(&req.notices, &state.config.prefix, &resource_key).await,
);
}
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.bulk)
{
crate::runique_log!(level, resource = %resource_key, action = "bulk GET", "bulk");
}
handle_bulk::handle_bulk_edit_get(&mut req, entry, &state, ¶ms).await
}
_ => Err(Box::new(AppError::new(ErrorContext::not_found(
"Unknown action",
)))),
}
}
#[allow(private_interfaces)]
pub async fn admin_post(
headers: axum::http::HeaderMap,
Path((resource_key, action)): Path<(String, String)>,
Extension(state): Extension<Arc<PrototypeAdminState>>,
Extension(current_user): Extension<CurrentUser>,
mut req: Request,
) -> AppResult<Response> {
let body = req.prisme.data.clone();
let entry = state
.registry
.get(&resource_key)
.ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Resource not found"))))?;
inject_context(&mut req, &state, entry, ¤t_user);
req.context.insert(ctx_common::LANG, ¤t_lang().code());
check_csrf(&body, req.csrf_token.as_str())?;
let can_create = check_can_create(¤t_user, &resource_key);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
user = %current_user.username,
action = %action,
can_create,
"POST access check"
);
}
if !can_create {
return Ok(permission_denied(&req.notices, &state.config.prefix, &resource_key).await);
}
match action.as_str() {
"create" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, action = "create POST", "crud");
}
handle_create_post(&mut req, entry, body, &headers, &state, ¤t_user).await
}
"bulk" => {
let bulk_action = body.get("bulk_action").map(String::as_str).unwrap_or("");
let can_bulk = if bulk_action == "delete" {
check_can_delete(¤t_user, &resource_key)
} else {
check_can_update(¤t_user, &resource_key)
};
if !can_bulk {
return Ok(
permission_denied(&req.notices, &state.config.prefix, &resource_key).await,
);
}
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.bulk)
{
crate::runique_log!(level, resource = %resource_key, action = "bulk POST", "bulk");
}
handle_bulk_action(&mut req, entry, body, &state, &resource_key, ¤t_user).await
}
_ => Err(Box::new(AppError::new(ErrorContext::not_found(
"Unknown action",
)))),
}
}
pub async fn admin_get_id(
Path((resource_key, id, action)): Path<(String, String, String)>,
Extension(state): Extension<Arc<PrototypeAdminState>>,
Extension(current_user): Extension<CurrentUser>,
mut req: Request,
) -> AppResult<Response> {
let entry = state
.registry
.get(&resource_key)
.ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Resource not found"))))?;
inject_context(&mut req, &state, entry, ¤t_user);
req.context.insert(ctx_common::LANG, ¤t_lang().code());
let can_access = current_user.can_access_resource(&resource_key);
let can_update = check_can_update(¤t_user, &resource_key);
let can_delete = check_can_delete(¤t_user, &resource_key);
let perm = current_user.permission_for(&resource_key);
let can_update_own =
current_user.is_superuser || perm.as_ref().is_some_and(|p| p.can_update_own);
let can_delete_own = current_user.is_superuser || perm.is_some_and(|p| p.can_delete_own);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
id = %id,
action = %action,
user = %current_user.username,
can_access,
can_update,
can_delete,
"id action access check"
);
}
if !can_access {
return Ok(permission_denied_dashboard(&req.notices, &state.config.prefix).await);
}
let owns_record = check_owns_record(entry, req.engine.db.clone(), &id, current_user.id).await;
if action == "edit" && !can_update && !(can_update_own && owns_record) {
return Ok(permission_denied(&req.notices, &state.config.prefix, &resource_key).await);
}
if action == "delete" && !can_delete && !(can_delete_own && owns_record) {
return Ok(permission_denied(&req.notices, &state.config.prefix, &resource_key).await);
}
match action.as_str() {
"detail" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "detail", "crud");
}
handle_detail(&mut req, entry, id, &state).await
}
"edit" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "edit GET", "crud");
}
handle_edit_get(&mut req, entry, id, &state).await
}
"delete" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "delete GET", "crud");
}
handle_delete_get(&mut req, entry, id, &state).await
}
_ => Err(Box::new(AppError::new(ErrorContext::not_found(
"Unknown action",
)))),
}
}
#[allow(private_interfaces)]
pub async fn admin_post_id(
headers: axum::http::HeaderMap,
Path((resource_key, id, action)): Path<(String, String, String)>,
Extension(state): Extension<Arc<PrototypeAdminState>>,
Extension(current_user): Extension<CurrentUser>,
mut req: Request,
) -> AppResult<Response> {
let body = req.prisme.data.clone();
let entry = state
.registry
.get(&resource_key)
.ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Resource not found"))))?;
inject_context(&mut req, &state, entry, ¤t_user);
req.context.insert(ctx_common::LANG, ¤t_lang().code());
check_csrf(&body, req.csrf_token.as_str())?;
let can_update = check_can_update(¤t_user, &resource_key);
let can_delete = check_can_delete(¤t_user, &resource_key);
let perm = current_user.permission_for(&resource_key);
let can_update_own =
current_user.is_superuser || perm.as_ref().is_some_and(|p| p.can_update_own);
let can_delete_own = current_user.is_superuser || perm.is_some_and(|p| p.can_delete_own);
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(
level,
resource = %resource_key,
id = %id,
action = %action,
user = %current_user.username,
can_update,
can_delete,
"id POST access check"
);
}
let owns_record = check_owns_record(entry, req.engine.db.clone(), &id, current_user.id).await;
if action == "edit" && !can_update && !(can_update_own && owns_record) {
return Ok(permission_denied(&req.notices, &state.config.prefix, &resource_key).await);
}
if action == "delete" && !can_delete && !(can_delete_own && owns_record) {
return Ok(permission_denied(&req.notices, &state.config.prefix, &resource_key).await);
}
match action.as_str() {
"edit" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "edit POST", "crud");
}
handle_edit_post(&mut req, entry, id, body, &state, ¤t_user).await
}
"delete" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "delete POST", "crud");
}
handle_delete_post(&mut req, entry, id, &state, ¤t_user).await
}
"reset-password" => {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.crud)
{
crate::runique_log!(level, resource = %resource_key, id = %id, action = "reset-password", "crud");
}
handle_reset_password(&mut req, entry, id, &headers, &state).await
}
_ => Err(Box::new(AppError::new(ErrorContext::not_found(
"Unknown action",
)))),
}
}
pub(super) fn inject_context(
req: &mut Request,
state: &PrototypeAdminState,
entry: &ResourceEntry,
current_user: &CurrentUser,
) {
for item in ["list", "create", "edit", "detail", "delete", "base"] {
insert_admin_messages(&mut req.context, item);
}
req.context
.insert(ctx_common::SITE_TITLE, &state.config.site_title);
req.context
.insert(ctx_common::SITE_URL, &state.config.site_url);
inject_admin_prefix(&mut req.context, &state.config.prefix);
req.context.insert(ctx_common::RESOURCE_KEY, entry.meta.key);
req.context
.insert(ctx_common::CURRENT_RESOURCE, entry.meta.key);
req.context.insert(ctx_common::RESOURCE, &entry.meta);
req.context
.insert(ctx_list::GROUP_ACTIONS, &entry.group_actions);
let visible_resources: Vec<_> = state
.registry
.all()
.filter(|e| {
if current_user.is_superuser {
return true;
}
current_user.can_access_resource(e.meta.key)
})
.map(|e| &e.meta)
.collect();
req.context
.insert(ctx_common::RESOURCES, &visible_resources);
for (k, v) in &entry.meta.extra_context {
req.context.insert(k, v);
}
let (can_create, can_read, can_update, can_delete, can_update_own, can_delete_own) =
if current_user.is_superuser {
(true, true, true, true, true, true)
} else {
match current_user.permission_for(entry.meta.key) {
Some(p) => (
p.can_create,
p.can_read,
p.can_update,
p.can_delete,
p.can_update_own,
p.can_delete_own,
),
None => (false, false, false, false, false, false),
}
};
req.context.insert(ctx_perm::CAN_CREATE, &can_create);
req.context.insert(ctx_perm::CAN_READ, &can_read);
req.context.insert(ctx_perm::CAN_UPDATE, &can_update);
req.context.insert(ctx_perm::CAN_DELETE, &can_delete);
req.context
.insert(ctx_perm::CAN_UPDATE_OWN, &can_update_own);
req.context
.insert(ctx_perm::CAN_DELETE_OWN, &can_delete_own);
}
async fn check_owns_record(
entry: &super::helper::resource_entry::ResourceEntry,
db: crate::utils::aliases::ADb,
id: &str,
user_id: crate::utils::pk::Pk,
) -> bool {
let own_field = match entry.own_field {
Some(f) => f,
None => return false,
};
let get_fn = match &entry.get_fn {
Some(f) => f,
None => return false,
};
let record = match get_fn(db, id.to_string()).await.ok().flatten() {
Some(v) => v,
None => return false,
};
let field_val = match record.get(own_field) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
None => return false,
};
field_val == user_id.to_string()
}
fn check_can_create(user: &CurrentUser, resource_key: &str) -> bool {
user.is_superuser
|| user
.permission_for(resource_key)
.is_some_and(|p| p.can_create)
}
fn check_can_update(user: &CurrentUser, resource_key: &str) -> bool {
user.is_superuser
|| user
.permission_for(resource_key)
.is_some_and(|p| p.can_update)
}
fn check_can_delete(user: &CurrentUser, resource_key: &str) -> bool {
user.is_superuser
|| user
.permission_for(resource_key)
.is_some_and(|p| p.can_delete)
}
async fn permission_denied(
notices: &crate::flash::flash_manager::Message,
prefix: &str,
resource_key: &str,
) -> Response {
notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
Redirect::to(&format!(
"{}/{}/list",
prefix.trim_end_matches('/'),
resource_key
))
.into_response()
}
pub(super) async fn permission_denied_dashboard(
notices: &crate::flash::flash_manager::Message,
prefix: &str,
) -> Response {
notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
Redirect::to(&format!("{}/", prefix.trim_end_matches('/'))).into_response()
}
fn check_csrf(body: &StrMap, session_token: &str) -> AppResult<()> {
let valid = body
.get(CSRF_TOKEN_KEY)
.map(|s| {
if let Ok(unmasked) = crate::utils::csrf::unmask_csrf_token(s) {
bool::from(unmasked.as_bytes().ct_eq(session_token.as_bytes()))
} else {
bool::from(s.as_bytes().ct_eq(session_token.as_bytes()))
}
})
.unwrap_or(false);
if !valid {
if let Some(level) = crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.auth)
{
crate::runique_log!(level, "CSRF validation failed");
}
return Err(Box::new(AppError::new(ErrorContext::generic(
StatusCode::FORBIDDEN,
t("csrf.invalid_or_missing").as_ref(),
))));
}
Ok(())
}