From 5eaafe775e22a371b83a1b2742f14d8b3d1b2074 Mon Sep 17 00:00:00 2001
From: Phillip Assmann
Date: Fri, 23 Oct 2026 16:23:51 +0200
Subject: [PATCH] refactor!: move auth state, auth middleware and the admin app
into their own modules
Prepares the auth refactor by moving auth-related modules:
* the authentication state (`Auth`) moves to `cot::auth::state`,
* the authentication middleware moves to `cot::middleware::auth`,
* the admin app moves to `cot::admin::app`,
* `cot::auth::db` is renamed to `cot::auth::user_pass` (including its
migrations), as it will hold the username/password backend.
BREAKING CHANGE: `cot::auth::db` is now `cot::auth::user_pass`.
---
cot-cli/src/project_template/src/main.rs | 2 +-
cot/src/admin.rs | 460 +-----------------
cot/src/admin/app.rs | 460 ++++++++++++++++++
cot/src/auth.rs | 406 +++-------------
cot/src/auth/state.rs | 285 +++++++++++
cot/src/auth/{db.rs => user_pass.rs} | 28 +-
cot/src/auth/{db => user_pass}/migrations.rs | 0
.../migrations/m_0001_initial.rs | 35 +-
cot/src/config.rs | 5 +-
cot/src/db/migrations/graph_export.rs | 2 +-
cot/src/middleware.rs | 191 +-------
cot/src/middleware/auth.rs | 203 ++++++++
cot/src/project.rs | 2 +-
cot/src/request/extractors.rs | 14 -
cot/src/test.rs | 4 +-
cot/tests/admin.rs | 2 +-
cot/tests/auth.rs | 2 +-
cot/tests/db_testing/migrations.rs | 2 +-
cot/tests/ui/unimplemented_admin_model.stderr | 2 +-
cot/tests/ui/unimplemented_db_model.stderr | 2 +-
cot/tests/ui/unimplemented_form.stderr | 4 +-
docs/admin-panel.md | 4 +-
docs/configuration.md | 2 +-
examples/admin/src/main.rs | 2 +-
examples/todo-list/src/main.rs | 2 +-
25 files changed, 1084 insertions(+), 1037 deletions(-)
create mode 100644 cot/src/admin/app.rs
create mode 100644 cot/src/auth/state.rs
rename cot/src/auth/{db.rs => user_pass.rs} (97%)
rename cot/src/auth/{db => user_pass}/migrations.rs (100%)
rename cot/src/auth/{db => user_pass}/migrations/m_0001_initial.rs (67%)
create mode 100644 cot/src/middleware/auth.rs
diff --git a/cot-cli/src/project_template/src/main.rs b/cot-cli/src/project_template/src/main.rs
index d22127abc..950a72a7a 100644
--- a/cot-cli/src/project_template/src/main.rs
+++ b/cot-cli/src/project_template/src/main.rs
@@ -1,6 +1,6 @@
mod migrations;
-use cot::auth::db::DatabaseUserApp;
+use cot::auth::user_pass::DatabaseUserApp;
use cot::cli::CliMetadata;
use cot::db::migrations::SyncDynMigration;
use cot::html::Html;
diff --git a/cot/src/admin.rs b/cot/src/admin.rs
index 5ebd50957..a5d3a6727 100644
--- a/cot/src/admin.rs
+++ b/cot/src/admin.rs
@@ -3,148 +3,25 @@
//! This module provides an administration panel for managing models
//! registered in the application, straight from the web interface.
+mod app;
+
use std::any::Any;
use std::marker::PhantomData;
+pub use app::AdminApp;
use async_trait::async_trait;
-use bytes::Bytes;
/// Implements the [`AdminModel`] trait for a struct.
///
/// This is a simple method for adding a database model to the admin panel.
/// Note that in order for this derive macro to work, the structure
/// **must** implement [`Model`](crate::db::Model) and
-/// [`Form`] traits. These can also be derived using the `#[model]` and
-/// `#[derive(Form)]` attributes.
+/// [`Form`](crate::form::Form) traits. These can also be derived using the
+/// `#[model]` and `#[derive(Form)]` attributes.
pub use cot_macros::AdminModel;
use derive_more::Debug;
-use serde::Deserialize;
-
-use crate::auth::Auth;
-use crate::common_types::Password;
-use crate::error::NotFound;
-use crate::form::{
- Form, FormContext, FormErrorTarget, FormField, FormFieldValidationError, FormResult,
-};
-use crate::html::Html;
-use crate::request::extractors::{FromRequestHead, Path, StaticFiles, UrlQuery};
-use crate::request::{Request, RequestExt, RequestHead};
-use crate::response::{IntoResponse, Response};
-use crate::router::{Router, Urls};
-use crate::static_files::StaticFile;
-use crate::{App, Error, Method, RequestHandler, Template, reverse_redirect};
-
-struct AdminAuthenticated(H, PhantomData T>);
-
-impl + Send + Sync> AdminAuthenticated {
- #[must_use]
- fn new(handler: H) -> Self {
- Self(handler, PhantomData)
- }
-}
-
-impl + Send + Sync> RequestHandler for AdminAuthenticated {
- async fn handle(&self, mut request: Request) -> crate::Result {
- let auth: Auth = request.extract_from_head().await?;
- if !auth.user().is_authenticated() {
- return Ok(reverse_redirect!(request, "login")?);
- }
- self.0.handle(request).await
- }
-}
-
-#[derive(Debug, FromRequestHead)]
-struct BaseContext {
- urls: Urls,
- static_files: StaticFiles,
-}
-
-async fn index(
- base_context: BaseContext,
- AdminModelManagers(managers): AdminModelManagers,
-) -> crate::Result {
- #[derive(Debug, Template)]
- #[template(path = "admin/model_list.html")]
- struct ModelListTemplate<'a> {
- ctx: &'a BaseContext,
- #[debug("..")]
- model_managers: Vec>,
- }
-
- let template = ModelListTemplate {
- ctx: &base_context,
- model_managers: managers,
- };
- Ok(Html::new(template.render()?))
-}
-
-#[derive(Debug, Form)]
-struct LoginForm {
- username: String,
- password: Password,
-}
-
-async fn login(
- base_context: BaseContext,
- auth: Auth,
- mut request: Request,
-) -> crate::Result {
- #[derive(Debug, Template)]
- #[template(path = "admin/login.html")]
- struct LoginTemplate<'a> {
- ctx: &'a BaseContext,
- form: ::Context,
- }
-
- let login_form_context = if request.method() == Method::GET {
- LoginForm::build_context(&mut request).await?
- } else if request.method() == Method::POST {
- let login_form = LoginForm::from_request(&mut request).await?;
- match login_form {
- FormResult::Ok(login_form) => {
- if authenticate(&auth, login_form).await? {
- return Ok(reverse_redirect!(base_context.urls, "index")?);
- }
-
- let mut context = LoginForm::build_context(&mut request).await?;
- context.add_error(
- FormErrorTarget::Form,
- FormFieldValidationError::from_static("Invalid username or password."),
- );
- context
- }
- FormResult::ValidationError(context) => context,
- }
- } else {
- panic!("Unexpected request method");
- };
-
- let template = LoginTemplate {
- ctx: &base_context,
- form: login_form_context,
- };
- Html::new(template.render()?).into_response()
-}
-
-async fn authenticate(auth: &Auth, login_form: LoginForm) -> crate::Result {
- #[cfg(feature = "db")]
- let user = auth
- .authenticate(&crate::auth::db::DatabaseUserCredentials::new(
- login_form.username,
- Password::new(login_form.password.into_string()),
- ))
- .await?;
-
- #[cfg(not(feature = "db"))]
- let user: Option> = None;
-
- if let Some(user) = user {
- auth.login(user).await?;
- Ok(true)
- } else {
- Ok(false)
- }
-}
+use crate::form::FormContext;
+use crate::request::Request;
/// Struct representing the pagination of objects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -154,15 +31,6 @@ pub struct Pagination {
}
impl Pagination {
- fn new(limit: u64, page: u64) -> Self {
- assert!(page > 0, "Page number must be greater than 0");
-
- Self {
- limit,
- offset: (page - 1) * limit,
- }
- }
-
/// Returns the limit of objects per page.
#[must_use]
pub fn limit(&self) -> u64 {
@@ -176,225 +44,6 @@ impl Pagination {
}
}
-#[derive(Debug, Deserialize)]
-struct PaginationParams {
- page: Option,
- page_size: Option,
-}
-
-async fn view_model(
- base_context: BaseContext,
- managers: AdminModelManagers,
- Path(model_name): Path,
- UrlQuery(pagination_params): UrlQuery,
- request: Request,
-) -> crate::Result {
- #[derive(Debug, Template)]
- #[template(path = "admin/model.html")]
- struct ModelTemplate<'a> {
- ctx: &'a BaseContext,
- #[debug("..")]
- model: &'a dyn AdminModelManager,
- #[debug("..")]
- objects: Vec>,
- page: u64,
- page_size: &'a u64,
- total_object_counts: u64,
- total_pages: u64,
- }
-
- const DEFAULT_PAGE_SIZE: u64 = 10;
-
- let manager = get_manager(managers, &model_name)?;
-
- let page = pagination_params.page.unwrap_or(1);
- let page_size = pagination_params.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
-
- let total_object_counts = manager.get_total_object_counts(&request).await?;
- let total_pages = total_object_counts.div_ceil(page_size);
-
- if (page == 0 || page > total_pages) && total_pages > 0 {
- return Err(Error::from(NotFound::with_message(format!(
- "page {page} not found"
- ))));
- }
-
- let pagination = Pagination::new(page_size, page);
-
- let objects = manager.get_objects(&request, pagination).await?;
-
- let template = ModelTemplate {
- ctx: &base_context,
- model: &*manager,
- objects,
- page,
- page_size: &page_size,
- total_object_counts,
- total_pages,
- };
-
- Html::new(template.render()?).into_response()
-}
-
-async fn create_model_instance(
- base_context: BaseContext,
- managers: AdminModelManagers,
- Path(model_name): Path,
- request: Request,
-) -> cot::Result {
- edit_model_instance_impl(base_context, managers, request, &model_name, None).await
-}
-
-async fn edit_model_instance(
- base_context: BaseContext,
- managers: AdminModelManagers,
- Path((model_name, object_id)): Path<(String, String)>,
- request: Request,
-) -> cot::Result {
- edit_model_instance_impl(
- base_context,
- managers,
- request,
- &model_name,
- Some(&object_id),
- )
- .await
-}
-
-async fn edit_model_instance_impl(
- base_context: BaseContext,
- managers: AdminModelManagers,
- mut request: Request,
- model_name: &str,
- object_id: Option<&str>,
-) -> cot::Result {
- #[derive(Debug, Template)]
- #[template(path = "admin/model_edit.html")]
- struct ModelEditTemplate<'a> {
- ctx: &'a BaseContext,
- #[debug("..")]
- model: &'a dyn AdminModelManager,
- form_context: Box,
- is_edit: bool,
- }
-
- let manager = get_manager(managers, model_name)?;
-
- let form_context = if request.method() == Method::POST {
- let form_context = manager.save_from_request(&mut request, object_id).await?;
-
- if let Some(form_context) = form_context {
- form_context
- } else {
- return Ok(reverse_redirect!(
- base_context.urls,
- "view_model",
- model_name = manager.url_name()
- )?);
- }
- } else if let Some(object_id) = object_id {
- let object = get_object(&mut request, &*manager, object_id).await?;
-
- manager.form_context_from_object(object).await
- } else {
- manager.form_context()
- };
-
- let template = ModelEditTemplate {
- ctx: &base_context,
- model: &*manager,
- form_context,
- is_edit: object_id.is_some(),
- };
-
- Html::new(template.render()?).into_response()
-}
-
-async fn remove_model_instance(
- base_context: BaseContext,
- managers: AdminModelManagers,
- Path((model_name, object_id)): Path<(String, String)>,
- mut request: Request,
-) -> cot::Result {
- #[derive(Debug, Template)]
- #[template(path = "admin/model_remove.html")]
- struct ModelRemoveTemplate<'a> {
- ctx: &'a BaseContext,
- #[debug("..")]
- model: &'a dyn AdminModelManager,
- #[debug("..")]
- object: &'a dyn AdminModel,
- }
-
- let manager = get_manager(managers, &model_name)?;
- let object = get_object(&mut request, &*manager, &object_id).await?;
-
- if request.method() == Method::POST {
- manager.remove_by_id(&mut request, &object_id).await?;
-
- Ok(reverse_redirect!(
- base_context.urls,
- "view_model",
- model_name = manager.url_name()
- )?)
- } else {
- let template = ModelRemoveTemplate {
- ctx: &base_context,
- model: &*manager,
- object: &*object,
- };
-
- Html::new(template.render()?).into_response()
- }
-}
-
-async fn get_object(
- request: &mut Request,
- manager: &dyn AdminModelManager,
- object_id: &str,
-) -> Result, Error> {
- manager
- .get_object_by_id(request, object_id)
- .await?
- .ok_or_else(|| {
- Error::from(NotFound::with_message(format!(
- "Object with ID `{}` not found in model `{}`",
- object_id,
- manager.name()
- )))
- })
-}
-
-fn get_manager(
- AdminModelManagers(model_managers): AdminModelManagers,
- model_name: &str,
-) -> cot::Result> {
- model_managers
- .into_iter()
- .find(|manager| manager.url_name() == model_name)
- .ok_or_else(|| {
- Error::from(NotFound::with_message(format!(
- "Model `{model_name}` not found"
- )))
- })
-}
-
-#[repr(transparent)]
-struct AdminModelManagers(Vec>);
-
-impl FromRequestHead for AdminModelManagers {
- #[expect(clippy::unused_async_trait_impl)]
- async fn from_request_head(head: &RequestHead) -> cot::Result {
- let managers = head
- .context()
- .apps()
- .iter()
- .flat_map(|app| app.admin_model_managers())
- .collect();
- Ok(Self(managers))
- }
-}
-
/// A trait for adding admin models to the app.
///
/// This exposes an API over [`AdminModel`] that is dyn-compatible and
@@ -619,98 +268,3 @@ pub trait AdminModel: Any + Send + 'static {
where
Self: Sized;
}
-
-/// The admin app.
-///
-/// # Examples
-///
-/// ```
-/// use cot::admin::AdminApp;
-/// use cot::project::RegisterAppsContext;
-/// use cot::{AppBuilder, Project};
-///
-/// struct MyProject;
-/// impl Project for MyProject {
-/// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
-/// apps.register_with_views(AdminApp::new(), "/admin");
-/// }
-/// }
-/// ```
-#[derive(Debug, Copy, Clone)]
-pub struct AdminApp;
-
-impl Default for AdminApp {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl AdminApp {
- /// Creates an admin app instance.
- ///
- /// # Examples
- ///
- /// ```
- /// use cot::admin::AdminApp;
- /// use cot::project::RegisterAppsContext;
- /// use cot::{AppBuilder, Project};
- ///
- /// struct MyProject;
- /// impl Project for MyProject {
- /// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
- /// apps.register_with_views(AdminApp::new(), "/admin");
- /// }
- /// }
- /// ```
- #[must_use]
- pub fn new() -> Self {
- Self {}
- }
-}
-
-impl App for AdminApp {
- fn name(&self) -> &'static str {
- "cot_admin"
- }
-
- fn router(&self) -> Router {
- Router::with_urls([
- crate::router::Route::with_handler_and_name(
- "/",
- AdminAuthenticated::new(index),
- "index",
- ),
- crate::router::Route::with_handler_and_name("/login/", login, "login"),
- crate::router::Route::with_handler_and_name(
- "/{model_name}/",
- AdminAuthenticated::new(view_model),
- "view_model",
- ),
- crate::router::Route::with_handler_and_name(
- "/{model_name}/create/",
- AdminAuthenticated::new(create_model_instance),
- "create_model_instance",
- ),
- crate::router::Route::with_handler_and_name(
- "/{model_name}/{pk}/edit/",
- AdminAuthenticated::new(edit_model_instance),
- "edit_model_instance",
- ),
- crate::router::Route::with_handler_and_name(
- "/{model_name}/{pk}/remove/",
- AdminAuthenticated::new(remove_model_instance),
- "remove_model_instance",
- ),
- ])
- }
-
- fn static_files(&self) -> Vec {
- vec![StaticFile::new(
- "admin/admin.css",
- Bytes::from_static(include_bytes!(concat!(
- env!("OUT_DIR"),
- "/static/admin/admin.css"
- ))),
- )]
- }
-}
diff --git a/cot/src/admin/app.rs b/cot/src/admin/app.rs
new file mode 100644
index 000000000..35cc12057
--- /dev/null
+++ b/cot/src/admin/app.rs
@@ -0,0 +1,460 @@
+//! The admin panel app and its views.
+
+use std::marker::PhantomData;
+
+use bytes::Bytes;
+use derive_more::Debug;
+use serde::Deserialize;
+
+use super::{AdminModel, AdminModelManager, Pagination};
+use crate::auth::Auth;
+use crate::common_types::Password;
+use crate::error::NotFound;
+use crate::form::{
+ Form, FormContext, FormErrorTarget, FormField, FormFieldValidationError, FormResult,
+};
+use crate::html::Html;
+use crate::request::extractors::{FromRequestHead, Path, StaticFiles, UrlQuery};
+use crate::request::{Request, RequestExt, RequestHead};
+use crate::response::{IntoResponse, Response};
+use crate::router::{Router, Urls};
+use crate::static_files::StaticFile;
+use crate::{App, Error, Method, RequestHandler, Template, reverse_redirect};
+
+struct AdminAuthenticated(H, PhantomData T>);
+
+impl + Send + Sync> AdminAuthenticated {
+ #[must_use]
+ fn new(handler: H) -> Self {
+ Self(handler, PhantomData)
+ }
+}
+
+impl + Send + Sync> RequestHandler for AdminAuthenticated {
+ async fn handle(&self, mut request: Request) -> crate::Result {
+ let auth: Auth = request.extract_from_head().await?;
+ if !auth.user().is_authenticated() {
+ return Ok(reverse_redirect!(request, "login")?);
+ }
+
+ self.0.handle(request).await
+ }
+}
+
+#[derive(Debug, FromRequestHead)]
+struct BaseContext {
+ urls: Urls,
+ static_files: StaticFiles,
+}
+
+async fn index(
+ base_context: BaseContext,
+ AdminModelManagers(managers): AdminModelManagers,
+) -> crate::Result {
+ #[derive(Debug, Template)]
+ #[template(path = "admin/model_list.html")]
+ struct ModelListTemplate<'a> {
+ ctx: &'a BaseContext,
+ #[debug("..")]
+ model_managers: Vec>,
+ }
+
+ let template = ModelListTemplate {
+ ctx: &base_context,
+ model_managers: managers,
+ };
+ Ok(Html::new(template.render()?))
+}
+
+#[derive(Debug, Form)]
+struct LoginForm {
+ username: String,
+ password: Password,
+}
+
+async fn login(
+ base_context: BaseContext,
+ auth: Auth,
+ mut request: Request,
+) -> crate::Result {
+ #[derive(Debug, Template)]
+ #[template(path = "admin/login.html")]
+ struct LoginTemplate<'a> {
+ ctx: &'a BaseContext,
+ form: ::Context,
+ }
+
+ let login_form_context = if request.method() == Method::GET {
+ LoginForm::build_context(&mut request).await?
+ } else if request.method() == Method::POST {
+ let login_form = LoginForm::from_request(&mut request).await?;
+ match login_form {
+ FormResult::Ok(login_form) => {
+ if authenticate(&auth, login_form).await? {
+ return Ok(reverse_redirect!(base_context.urls, "index")?);
+ }
+
+ let mut context = LoginForm::build_context(&mut request).await?;
+ context.add_error(
+ FormErrorTarget::Form,
+ FormFieldValidationError::from_static("Invalid username or password."),
+ );
+ context
+ }
+ FormResult::ValidationError(context) => context,
+ }
+ } else {
+ panic!("Unexpected request method");
+ };
+
+ let template = LoginTemplate {
+ ctx: &base_context,
+ form: login_form_context,
+ };
+ Html::new(template.render()?).into_response()
+}
+
+async fn authenticate(auth: &Auth, login_form: LoginForm) -> crate::Result {
+ #[cfg(feature = "db")]
+ let user = auth
+ .authenticate(&crate::auth::user_pass::DatabaseUserCredentials::new(
+ login_form.username,
+ Password::new(login_form.password.into_string()),
+ ))
+ .await?;
+
+ #[cfg(not(feature = "db"))]
+ let user: Option> = None;
+
+ if let Some(user) = user {
+ auth.login(user).await?;
+ Ok(true)
+ } else {
+ Ok(false)
+ }
+}
+
+impl Pagination {
+ fn new(limit: u64, page: u64) -> Self {
+ assert!(page > 0, "Page number must be greater than 0");
+
+ Self {
+ limit,
+ offset: (page - 1) * limit,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct PaginationParams {
+ page: Option,
+ page_size: Option,
+}
+
+async fn view_model(
+ base_context: BaseContext,
+ managers: AdminModelManagers,
+ Path(model_name): Path,
+ UrlQuery(pagination_params): UrlQuery,
+ request: Request,
+) -> crate::Result {
+ #[derive(Debug, Template)]
+ #[template(path = "admin/model.html")]
+ struct ModelTemplate<'a> {
+ ctx: &'a BaseContext,
+ #[debug("..")]
+ model: &'a dyn AdminModelManager,
+ #[debug("..")]
+ objects: Vec>,
+ page: u64,
+ page_size: &'a u64,
+ total_object_counts: u64,
+ total_pages: u64,
+ }
+
+ const DEFAULT_PAGE_SIZE: u64 = 10;
+
+ let manager = get_manager(managers, &model_name)?;
+
+ let page = pagination_params.page.unwrap_or(1);
+ let page_size = pagination_params.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
+
+ let total_object_counts = manager.get_total_object_counts(&request).await?;
+ let total_pages = total_object_counts.div_ceil(page_size);
+
+ if (page == 0 || page > total_pages) && total_pages > 0 {
+ return Err(Error::from(NotFound::with_message(format!(
+ "page {page} not found"
+ ))));
+ }
+
+ let pagination = Pagination::new(page_size, page);
+
+ let objects = manager.get_objects(&request, pagination).await?;
+
+ let template = ModelTemplate {
+ ctx: &base_context,
+ model: &*manager,
+ objects,
+ page,
+ page_size: &page_size,
+ total_object_counts,
+ total_pages,
+ };
+
+ Html::new(template.render()?).into_response()
+}
+
+async fn create_model_instance(
+ base_context: BaseContext,
+ managers: AdminModelManagers,
+ Path(model_name): Path,
+ request: Request,
+) -> cot::Result {
+ edit_model_instance_impl(base_context, managers, request, &model_name, None).await
+}
+
+async fn edit_model_instance(
+ base_context: BaseContext,
+ managers: AdminModelManagers,
+ Path((model_name, object_id)): Path<(String, String)>,
+ request: Request,
+) -> cot::Result {
+ edit_model_instance_impl(
+ base_context,
+ managers,
+ request,
+ &model_name,
+ Some(&object_id),
+ )
+ .await
+}
+
+async fn edit_model_instance_impl(
+ base_context: BaseContext,
+ managers: AdminModelManagers,
+ mut request: Request,
+ model_name: &str,
+ object_id: Option<&str>,
+) -> cot::Result {
+ #[derive(Debug, Template)]
+ #[template(path = "admin/model_edit.html")]
+ struct ModelEditTemplate<'a> {
+ ctx: &'a BaseContext,
+ #[debug("..")]
+ model: &'a dyn AdminModelManager,
+ form_context: Box,
+ is_edit: bool,
+ }
+
+ let manager = get_manager(managers, model_name)?;
+
+ let form_context = if request.method() == Method::POST {
+ let form_context = manager.save_from_request(&mut request, object_id).await?;
+
+ if let Some(form_context) = form_context {
+ form_context
+ } else {
+ return Ok(reverse_redirect!(
+ base_context.urls,
+ "view_model",
+ model_name = manager.url_name()
+ )?);
+ }
+ } else if let Some(object_id) = object_id {
+ let object = get_object(&mut request, &*manager, object_id).await?;
+
+ manager.form_context_from_object(object).await
+ } else {
+ manager.form_context()
+ };
+
+ let template = ModelEditTemplate {
+ ctx: &base_context,
+ model: &*manager,
+ form_context,
+ is_edit: object_id.is_some(),
+ };
+
+ Html::new(template.render()?).into_response()
+}
+
+async fn remove_model_instance(
+ base_context: BaseContext,
+ managers: AdminModelManagers,
+ Path((model_name, object_id)): Path<(String, String)>,
+ mut request: Request,
+) -> cot::Result {
+ #[derive(Debug, Template)]
+ #[template(path = "admin/model_remove.html")]
+ struct ModelRemoveTemplate<'a> {
+ ctx: &'a BaseContext,
+ #[debug("..")]
+ model: &'a dyn AdminModelManager,
+ #[debug("..")]
+ object: &'a dyn AdminModel,
+ }
+
+ let manager = get_manager(managers, &model_name)?;
+ let object = get_object(&mut request, &*manager, &object_id).await?;
+
+ if request.method() == Method::POST {
+ manager.remove_by_id(&mut request, &object_id).await?;
+
+ Ok(reverse_redirect!(
+ base_context.urls,
+ "view_model",
+ model_name = manager.url_name()
+ )?)
+ } else {
+ let template = ModelRemoveTemplate {
+ ctx: &base_context,
+ model: &*manager,
+ object: &*object,
+ };
+
+ Html::new(template.render()?).into_response()
+ }
+}
+
+async fn get_object(
+ request: &mut Request,
+ manager: &dyn AdminModelManager,
+ object_id: &str,
+) -> Result, Error> {
+ manager
+ .get_object_by_id(request, object_id)
+ .await?
+ .ok_or_else(|| {
+ Error::from(NotFound::with_message(format!(
+ "Object with ID `{}` not found in model `{}`",
+ object_id,
+ manager.name()
+ )))
+ })
+}
+
+fn get_manager(
+ AdminModelManagers(model_managers): AdminModelManagers,
+ model_name: &str,
+) -> cot::Result> {
+ model_managers
+ .into_iter()
+ .find(|manager| manager.url_name() == model_name)
+ .ok_or_else(|| {
+ Error::from(NotFound::with_message(format!(
+ "Model `{model_name}` not found"
+ )))
+ })
+}
+
+#[repr(transparent)]
+struct AdminModelManagers(Vec>);
+
+impl FromRequestHead for AdminModelManagers {
+ #[expect(clippy::unused_async_trait_impl)]
+ async fn from_request_head(head: &RequestHead) -> cot::Result {
+ let managers = head
+ .context()
+ .apps()
+ .iter()
+ .flat_map(|app| app.admin_model_managers())
+ .collect();
+ Ok(Self(managers))
+ }
+}
+
+/// The admin app.
+///
+/// # Examples
+///
+/// ```
+/// use cot::admin::AdminApp;
+/// use cot::project::RegisterAppsContext;
+/// use cot::{AppBuilder, Project};
+///
+/// struct MyProject;
+/// impl Project for MyProject {
+/// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
+/// apps.register_with_views(AdminApp::new(), "/admin");
+/// }
+/// }
+/// ```
+#[derive(Debug, Copy, Clone)]
+pub struct AdminApp;
+
+impl Default for AdminApp {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl AdminApp {
+ /// Creates an admin app instance.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use cot::admin::AdminApp;
+ /// use cot::project::RegisterAppsContext;
+ /// use cot::{AppBuilder, Project};
+ ///
+ /// struct MyProject;
+ /// impl Project for MyProject {
+ /// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
+ /// apps.register_with_views(AdminApp::new(), "/admin");
+ /// }
+ /// }
+ /// ```
+ #[must_use]
+ pub fn new() -> Self {
+ Self {}
+ }
+}
+
+impl App for AdminApp {
+ fn name(&self) -> &'static str {
+ "cot_admin"
+ }
+
+ fn router(&self) -> Router {
+ Router::with_urls([
+ crate::router::Route::with_handler_and_name(
+ "/",
+ AdminAuthenticated::new(index),
+ "index",
+ ),
+ crate::router::Route::with_handler_and_name("/login/", login, "login"),
+ crate::router::Route::with_handler_and_name(
+ "/{model_name}/",
+ AdminAuthenticated::new(view_model),
+ "view_model",
+ ),
+ crate::router::Route::with_handler_and_name(
+ "/{model_name}/create/",
+ AdminAuthenticated::new(create_model_instance),
+ "create_model_instance",
+ ),
+ crate::router::Route::with_handler_and_name(
+ "/{model_name}/{pk}/edit/",
+ AdminAuthenticated::new(edit_model_instance),
+ "edit_model_instance",
+ ),
+ crate::router::Route::with_handler_and_name(
+ "/{model_name}/{pk}/remove/",
+ AdminAuthenticated::new(remove_model_instance),
+ "remove_model_instance",
+ ),
+ ])
+ }
+
+ fn static_files(&self) -> Vec {
+ vec![StaticFile::new(
+ "admin/admin.css",
+ Bytes::from_static(include_bytes!(concat!(
+ env!("OUT_DIR"),
+ "/static/admin/admin.css"
+ ))),
+ )]
+ }
+}
diff --git a/cot/src/auth.rs b/cot/src/auth.rs
index a536dac2c..a97e4d66f 100644
--- a/cot/src/auth.rs
+++ b/cot/src/auth.rs
@@ -6,12 +6,13 @@
//!
//! For the default way to store users in the database, see the [`db`] module.
+mod state;
#[cfg(feature = "db")]
-pub mod db;
+pub mod user_pass;
use std::any::Any;
use std::borrow::Cow;
-use std::sync::{Arc, Mutex, MutexGuard};
+use std::sync::Arc;
/// backwards compatible shim for form Password type.
use async_trait::async_trait;
@@ -22,16 +23,11 @@ use derive_more::with_trait::Debug;
use mockall::automock;
use password_auth::VerifyError;
use serde::{Deserialize, Serialize};
+pub use state::Auth;
use subtle::ConstantTimeEq;
use thiserror::Error;
use crate::config::SecretKey;
-#[cfg(feature = "db")]
-use crate::db::{
- ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, TextField, ToDbValue,
-};
-use crate::request::{Request, RequestExt};
-use crate::session::Session;
const ERROR_PREFIX: &str = "failed to authenticate user:";
@@ -87,8 +83,8 @@ pub type Result = std::result::Result;
///
/// A concrete instance of a `User` object is returned by a backend that
/// implements the [`AuthBackend`] trait. The default backend is the
-/// [`DatabaseUserBackend`](db::DatabaseUserBackend), which stores user data in
-/// the database using Cot ORM.
+/// [`DatabaseUserBackend`](user_pass::DatabaseUserBackend), which stores user
+/// data in the database using Cot ORM.
#[cfg_attr(test, automock)]
pub trait User {
/// Returns the user's ID.
@@ -159,7 +155,7 @@ pub trait User {
/// This is used to verify that the session hash stored in the session
/// object is valid. If the session hash is not valid, the user is
/// logged out. For instance,
- /// [`DatabaseUser`](db::DatabaseUser) implements this method
+ /// [`DatabaseUser`](user_pass::DatabaseUser) implements this method
/// to generate a session hash using the user's password hash.
/// Therefore, when a user changes their password, the session hash is
/// also changed, and all their sessions are invalidated.
@@ -653,345 +649,88 @@ impl Debug for PasswordHash {
const MAX_PASSWORD_HASH_LENGTH: u32 = 128;
#[cfg(feature = "db")]
-impl DatabaseField for PasswordHash {
- const TYPE: ColumnType = ColumnType::String(MAX_PASSWORD_HASH_LENGTH);
-}
-
-#[cfg(feature = "db")]
-impl FromDbValue for PasswordHash {
- #[cfg(feature = "sqlite")]
- fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result {
- PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode)
- }
-
- #[cfg(feature = "postgres")]
- fn from_postgres(
- value: crate::db::impl_postgres::PostgresValueRef<'_>,
- ) -> cot::db::Result {
- PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode)
- }
-
- #[cfg(feature = "mysql")]
- fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef<'_>) -> crate::db::Result
- where
- Self: Sized,
- {
- PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode)
- }
-}
-
-#[cfg(feature = "db")]
-impl ToDbValue for PasswordHash {
- fn to_db_value(&self) -> DbValue {
- self.0.clone().into()
- }
-}
-
-#[cfg(feature = "db")]
-impl FromDbValue for Option {
- #[cfg(feature = "sqlite")]
- fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result {
- value
- .get::