use std::collections::HashMap;
use std::sync::Arc;
use axum::Router;
use laterite_auth::{AuditEntry, AuthService, AuthenticatedUser};
use laterite_core::{Db, Events, Registry, Text};
use crate::form::{FormField, FormHandle, Forms, ValueForm};
use crate::list::ImportLink;
#[derive(Clone)]
pub struct RouteCtx {
db: Db,
auth: AuthService,
field_types: Arc<crate::field::FieldRegistry>,
overrides: Arc<dyn crate::field::OverrideResolver>,
signing_key: Arc<Vec<u8>>,
storage_dir: Arc<str>,
base_path: Arc<str>,
admin_path: Arc<str>,
base_url: Arc<str>,
plugin_defined: Arc<Registry>,
events: Events,
forms: Forms,
}
impl RouteCtx {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
db: Db,
auth: AuthService,
field_types: Arc<crate::field::FieldRegistry>,
overrides: Arc<dyn crate::field::OverrideResolver>,
signing_key: Arc<Vec<u8>>,
storage_dir: &str,
base_path: &str,
admin_path: &str,
base_url: &str,
plugin_defined: Arc<Registry>,
events: Events,
forms: Forms,
) -> Self {
Self {
db,
auth,
field_types,
overrides,
signing_key,
storage_dir: Arc::from(storage_dir.trim_end_matches('/')),
base_path: Arc::from(base_path),
admin_path: Arc::from(admin_path),
base_url: Arc::from(base_url),
plugin_defined,
events,
forms,
}
}
pub async fn audit(
&self,
user: &AuthenticatedUser,
action: &str,
target_type: Option<&str>,
target_id: Option<&str>,
target_label: Option<&str>,
detail: Option<&str>,
) {
let actor = laterite_core::Actor::user(user.user.id, user.user.username.clone());
let mut entry = AuditEntry::new(&actor, action);
if let (Some(kind), Some(id)) = (target_type, target_id) {
entry = entry.target(kind, id, target_label);
}
if let Some(detail) = detail {
entry = entry.detail(detail);
}
if let Err(e) = self.auth.record_audit(entry).await {
tracing::error!(action, error = %e, "failed to write audit log entry");
}
}
pub fn value_form(&self, fields: &[FormField], scope: &str) -> Result<ValueForm, Text> {
ValueForm::new(
fields,
self.field_types.clone(),
self.overrides.clone(),
scope,
)
}
pub async fn operator(&self, headers: &axum::http::HeaderMap) -> Option<AuthenticatedUser> {
let token = headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|line| line.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(name, _)| *name == crate::SESSION_COOKIE)
.map(|(_, value)| value.trim().to_string())?;
self.auth.verify_session(&token).await.ok()
}
pub async fn record_unserved(&self, method: &str, path: &str, status: u16) {
crate::requestlog::record(&self.db, method, path, status).await;
}
pub fn storage_dir(&self) -> &str {
&self.storage_dir
}
pub fn sign(&self, message: &[u8]) -> String {
sign_with(&self.signing_key, message)
}
pub fn verify_signature(&self, message: &[u8], signature: &str) -> bool {
verify_with(&self.signing_key, message, signature)
}
pub fn db(&self) -> &Db {
&self.db
}
pub fn form(&self, path: &str) -> Option<FormHandle> {
self.forms
.get(path)
.map(|form| FormHandle::new(self.db.clone(), form.clone()))
}
pub fn events(&self) -> &Events {
&self.events
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
self.plugin_defined.items::<T>()
}
pub fn base_path(&self) -> &str {
&self.base_path
}
pub fn admin_path(&self) -> &str {
&self.admin_path
}
pub fn url(&self, relative: &str) -> String {
join(&self.base_path, relative)
}
pub fn builder(db: Db) -> RouteCtxBuilder {
RouteCtxBuilder {
db,
auth: None,
base_path: "/".to_string(),
admin_path: "/admin".to_string(),
base_url: "http://localhost".to_string(),
plugin_defined: None,
events: None,
}
}
}
pub struct RouteCtxBuilder {
db: Db,
auth: Option<AuthService>,
base_path: String,
admin_path: String,
base_url: String,
plugin_defined: Option<Arc<Registry>>,
events: Option<Events>,
}
impl RouteCtxBuilder {
pub fn base_path(mut self, path: impl Into<String>) -> Self {
self.base_path = path.into();
self
}
pub fn admin_path(mut self, path: impl Into<String>) -> Self {
self.admin_path = path.into();
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
self.plugin_defined = Some(registry);
self
}
pub fn events(mut self, events: Events) -> Self {
self.events = Some(events);
self
}
pub fn auth(mut self, auth: AuthService) -> Self {
self.auth = Some(auth);
self
}
pub fn build(self) -> RouteCtx {
let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
let auth = self.auth.unwrap_or_else(|| {
AuthService::new(self.db.clone(), laterite_auth::AuthConfig::default())
});
RouteCtx::new(
self.db,
auth,
Arc::new(crate::field::builtin_registry()),
Arc::new(crate::field::NoOverrides),
Arc::new(crate::random_key()),
"storage",
&self.base_path,
&self.admin_path,
&self.base_url,
self.plugin_defined
.unwrap_or_else(|| Arc::new(Registry::new())),
events,
Arc::new(HashMap::new()),
)
}
}
fn join(base: &str, relative: &str) -> String {
let base = base.trim_end_matches('/');
let relative = relative.trim_start_matches('/');
if relative.is_empty() {
base.to_string()
} else {
format!("{base}/{relative}")
}
}
pub trait Screen: Send + Sync + 'static {
fn mount(&self, ctx: &RouteCtx) -> Router;
}
#[non_exhaustive]
pub struct ScreenReg {
pub base_path: String,
pub nav_label: Option<Text>,
pub menu: crate::nav::Placement,
pub permission: String,
pub screen: Arc<dyn Screen>,
}
impl ScreenReg {
pub fn new(
base_path: impl Into<String>,
permission: impl Into<String>,
screen: Arc<dyn Screen>,
) -> Self {
Self {
base_path: base_path.into(),
nav_label: None,
menu: crate::nav::Placement::default(),
permission: permission.into(),
screen,
}
}
pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
self.nav_label = Some(label.into());
self
}
pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
self.menu = placement;
self
}
}
pub trait PublicRoute: Send + Sync + 'static {
fn mount(&self, ctx: &RouteCtx) -> Router;
}
#[non_exhaustive]
pub struct PublicRouteReg {
pub path: String,
pub route: Arc<dyn PublicRoute>,
}
impl PublicRouteReg {
pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
Self {
path: path.into(),
route,
}
}
pub fn site(route: Arc<dyn PublicRoute>) -> Self {
Self::new("/", route)
}
pub fn is_site(&self) -> bool {
self.path == "/"
}
}
pub trait ResourceFeature: Send + Sync + 'static {
fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
}
#[non_exhaustive]
pub struct ResourceFeatureReg {
pub key: String,
pub feature: Arc<dyn ResourceFeature>,
}
impl ResourceFeatureReg {
pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
Self {
key: key.into(),
feature,
}
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct ResourceInfo {
pub path: String,
pub base_path: String,
pub label: Text,
pub permission: String,
pub form: FormHandle,
}
impl ResourceInfo {
pub(crate) fn new(
path: String,
base_path: String,
label: Text,
permission: String,
form: FormHandle,
) -> Self {
Self {
path,
base_path,
label,
permission,
form,
}
}
}
pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
ImportLink {
href: format!("{base_path}/{key}"),
permission: permission.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_self_link_builds_from_where_the_screen_actually_mounted() {
let base = "/admin/rainmill/location/import";
assert_eq!(
join(base, "/step2"),
"/admin/rainmill/location/import/step2"
);
assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
assert_eq!(join(base, ""), "/admin/rainmill/location/import");
}
#[test]
fn a_moved_screen_moves_its_links_with_it() {
assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
}
#[test]
fn a_registration_defaults_to_no_menu_entry() {
struct Noop;
impl Screen for Noop {
fn mount(&self, _ctx: &RouteCtx) -> Router {
Router::new()
}
}
let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
assert!(reg.nav_label.is_none());
assert!(reg.in_menu("Import").nav_label.is_some());
}
}
pub(crate) fn sign_with(key: &[u8], message: &[u8]) -> String {
use hmac::Mac;
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length");
mac.update(message);
mac.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub(crate) fn verify_with(key: &[u8], message: &[u8], signature: &str) -> bool {
use hmac::Mac;
let Some(given) = unhex(signature) else {
return false;
};
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length");
mac.update(message);
mac.verify_slice(&given).is_ok()
}
pub(crate) fn unhex(hex: &str) -> Option<Vec<u8>> {
if !hex.len().is_multiple_of(2) || !hex.is_ascii() {
return None;
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod signing_tests {
use super::*;
#[test]
fn a_signature_verifies_against_its_key_alone() {
let sig = sign_with(b"key-one", b"message");
assert_eq!(sig.len(), 64);
assert!(verify_with(b"key-one", b"message", &sig));
assert!(!verify_with(b"key-two", b"message", &sig));
assert!(!verify_with(b"key-one", b"other", &sig));
assert!(!verify_with(b"key-one", b"message", "zz"));
assert!(!verify_with(b"key-one", b"message", &sig[..62]));
}
}