feat: add SeaORM entities, cache, auth helpers, and AppState

This commit is contained in:
Till Wegmueller 2026-04-03 19:24:14 +02:00
parent c993f4d703
commit 1d4873ba75
No known key found for this signature in database
10 changed files with 451 additions and 0 deletions

44
src/auth.rs Normal file
View file

@ -0,0 +1,44 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use base64::Engine;
use rand::Rng;
/// Generate a prefixed token: `{id}.{random_secret}`.
/// The id allows O(1) lookup; the secret is verified via argon2.
/// The `id` parameter is the entity UUID this token belongs to.
pub fn generate_token(id: &str) -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
format!("{id}.{secret}")
}
/// Generate a non-prefixed secret (for registration secrets that don't need lookup).
pub fn generate_secret() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// Hash a token (or its secret part) with argon2.
pub fn hash_token(token: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
Ok(argon2.hash_password(token.as_bytes(), &salt)?.to_string())
}
/// Verify a token against a stored argon2 hash.
pub fn verify_token(token: &str, hash: &str) -> bool {
let Ok(parsed_hash) = PasswordHash::new(hash) else {
return false;
};
Argon2::default()
.verify_password(token.as_bytes(), &parsed_hash)
.is_ok()
}
/// Split a prefixed token into (id, secret).
/// Returns None if the token is not in `id.secret` format.
pub fn split_token(token: &str) -> Option<(&str, &str)> {
token.split_once('.')
}

167
src/cache.rs Normal file
View file

@ -0,0 +1,167 @@
use dashmap::DashMap;
use sea_orm::*;
use std::sync::Arc;
use crate::entity::{links, resources};
#[derive(Debug, Clone)]
pub struct CachedLink {
pub rel: String,
pub href: Option<String>,
pub link_type: Option<String>,
pub titles: Option<String>,
pub properties: Option<String>,
pub template: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CachedResource {
pub subject: String,
pub aliases: Option<Vec<String>>,
pub properties: Option<serde_json::Value>,
pub links: Vec<CachedLink>,
}
#[derive(Debug, Clone)]
pub struct Cache {
inner: Arc<DashMap<String, CachedResource>>,
}
impl Cache {
pub fn new() -> Self {
Self {
inner: Arc::new(DashMap::new()),
}
}
pub fn get(&self, resource_uri: &str) -> Option<CachedResource> {
self.inner.get(resource_uri).map(|r| r.value().clone())
}
pub fn set(&self, resource_uri: String, resource: CachedResource) {
self.inner.insert(resource_uri, resource);
}
pub fn remove(&self, resource_uri: &str) {
self.inner.remove(resource_uri);
}
/// Remove all cache entries for the given resource URIs.
/// Callers should query the DB for all resource URIs belonging to a domain
/// before deleting, then pass them here. This handles non-acct: URI schemes.
pub fn remove_many(&self, resource_uris: &[String]) {
for uri in resource_uris {
self.inner.remove(uri.as_str());
}
}
/// Load all non-expired resources and links from DB into cache.
pub async fn hydrate(&self, db: &DatabaseConnection) -> Result<(), DbErr> {
let now = chrono::Utc::now().naive_utc();
let all_resources = resources::Entity::find().all(db).await?;
for resource in all_resources {
let resource_links = links::Entity::find()
.filter(links::Column::ResourceId.eq(&resource.id))
.filter(
Condition::any()
.add(links::Column::ExpiresAt.is_null())
.add(links::Column::ExpiresAt.gt(now)),
)
.all(db)
.await?;
if resource_links.is_empty() {
continue;
}
let cached = CachedResource {
subject: resource.resource_uri.clone(),
aliases: resource
.aliases
.as_deref()
.and_then(|a| serde_json::from_str(a).ok()),
properties: resource
.properties
.as_deref()
.and_then(|p| serde_json::from_str(p).ok()),
links: resource_links
.into_iter()
.map(|l| CachedLink {
rel: l.rel,
href: l.href,
link_type: l.link_type,
titles: l.titles,
properties: l.properties,
template: l.template,
})
.collect(),
};
self.set(resource.resource_uri, cached);
}
Ok(())
}
/// Rebuild cache entry for a single resource from DB.
pub async fn refresh_resource(
&self,
db: &DatabaseConnection,
resource_uri: &str,
) -> Result<(), DbErr> {
let now = chrono::Utc::now().naive_utc();
let resource = resources::Entity::find()
.filter(resources::Column::ResourceUri.eq(resource_uri))
.one(db)
.await?;
let Some(resource) = resource else {
self.remove(resource_uri);
return Ok(());
};
let resource_links = links::Entity::find()
.filter(links::Column::ResourceId.eq(&resource.id))
.filter(
Condition::any()
.add(links::Column::ExpiresAt.is_null())
.add(links::Column::ExpiresAt.gt(now)),
)
.all(db)
.await?;
if resource_links.is_empty() {
self.remove(resource_uri);
return Ok(());
}
let cached = CachedResource {
subject: resource.resource_uri.clone(),
aliases: resource
.aliases
.as_deref()
.and_then(|a| serde_json::from_str(a).ok()),
properties: resource
.properties
.as_deref()
.and_then(|p| serde_json::from_str(p).ok()),
links: resource_links
.into_iter()
.map(|l| CachedLink {
rel: l.rel,
href: l.href,
link_type: l.link_type,
titles: l.titles,
properties: l.properties,
template: l.template,
})
.collect(),
};
self.set(resource.resource_uri, cached);
Ok(())
}
}

25
src/challenge.rs Normal file
View file

@ -0,0 +1,25 @@
use async_trait::async_trait;
/// Trait for verifying domain ownership challenges (DNS-01 and HTTP-01).
#[async_trait]
pub trait ChallengeVerifier: Send + Sync + std::fmt::Debug {
async fn verify_dns01(&self, domain: &str, expected_token: &str) -> Result<bool, String>;
async fn verify_http01(&self, domain: &str, expected_token: &str) -> Result<bool, String>;
}
/// Production implementation using real DNS and HTTP lookups.
#[derive(Debug)]
pub struct RealChallengeVerifier;
#[async_trait]
impl ChallengeVerifier for RealChallengeVerifier {
async fn verify_dns01(&self, _domain: &str, _expected_token: &str) -> Result<bool, String> {
// TODO: implement with hickory-resolver in a later task
Ok(false)
}
async fn verify_http01(&self, _domain: &str, _expected_token: &str) -> Result<bool, String> {
// TODO: implement with reqwest in a later task
Ok(false)
}
}

47
src/entity/domains.rs Normal file
View file

@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "domains")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
#[sea_orm(unique)]
pub domain: String,
pub owner_token_hash: String,
pub registration_secret: String,
pub challenge_type: String,
pub challenge_token: Option<String>,
pub verified: bool,
pub created_at: chrono::NaiveDateTime,
pub verified_at: Option<chrono::NaiveDateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::resources::Entity")]
Resources,
#[sea_orm(has_many = "super::service_tokens::Entity")]
ServiceTokens,
#[sea_orm(has_many = "super::links::Entity")]
Links,
}
impl Related<super::resources::Entity> for Entity {
fn to() -> RelationDef {
Relation::Resources.def()
}
}
impl Related<super::service_tokens::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServiceTokens.def()
}
}
impl Related<super::links::Entity> for Entity {
fn to() -> RelationDef {
Relation::Links.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

63
src/entity/links.rs Normal file
View file

@ -0,0 +1,63 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "links")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub resource_id: String,
pub service_token_id: String,
pub domain_id: String,
pub rel: String,
pub href: Option<String>,
#[sea_orm(column_name = "type")]
pub link_type: Option<String>,
pub titles: Option<String>,
pub properties: Option<String>,
pub template: Option<String>,
pub ttl_seconds: Option<i32>,
pub created_at: chrono::NaiveDateTime,
pub expires_at: Option<chrono::NaiveDateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::resources::Entity",
from = "Column::ResourceId",
to = "super::resources::Column::Id"
)]
Resource,
#[sea_orm(
belongs_to = "super::service_tokens::Entity",
from = "Column::ServiceTokenId",
to = "super::service_tokens::Column::Id"
)]
ServiceToken,
#[sea_orm(
belongs_to = "super::domains::Entity",
from = "Column::DomainId",
to = "super::domains::Column::Id"
)]
Domain,
}
impl Related<super::resources::Entity> for Entity {
fn to() -> RelationDef {
Relation::Resource.def()
}
}
impl Related<super::service_tokens::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServiceToken.def()
}
}
impl Related<super::domains::Entity> for Entity {
fn to() -> RelationDef {
Relation::Domain.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

4
src/entity/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod domains;
pub mod links;
pub mod resources;
pub mod service_tokens;

41
src/entity/resources.rs Normal file
View file

@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "resources")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub domain_id: String,
#[sea_orm(unique)]
pub resource_uri: String,
pub aliases: Option<String>,
pub properties: Option<String>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::domains::Entity",
from = "Column::DomainId",
to = "super::domains::Column::Id"
)]
Domain,
#[sea_orm(has_many = "super::links::Entity")]
Links,
}
impl Related<super::domains::Entity> for Entity {
fn to() -> RelationDef {
Relation::Domain.def()
}
}
impl Related<super::links::Entity> for Entity {
fn to() -> RelationDef {
Relation::Links.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "service_tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub domain_id: String,
pub name: String,
pub token_hash: String,
pub allowed_rels: String,
pub resource_pattern: String,
pub created_at: chrono::NaiveDateTime,
pub revoked_at: Option<chrono::NaiveDateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::domains::Entity",
from = "Column::DomainId",
to = "super::domains::Column::Id"
)]
Domain,
#[sea_orm(has_many = "super::links::Entity")]
Links,
}
impl Related<super::domains::Entity> for Entity {
fn to() -> RelationDef {
Relation::Domain.def()
}
}
impl Related<super::links::Entity> for Entity {
fn to() -> RelationDef {
Relation::Links.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,2 +1,7 @@
pub mod auth;
pub mod cache;
pub mod challenge;
pub mod config;
pub mod entity;
pub mod error;
pub mod state;

14
src/state.rs Normal file
View file

@ -0,0 +1,14 @@
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use crate::cache::Cache;
use crate::challenge::ChallengeVerifier;
use crate::config::Settings;
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
pub cache: Cache,
pub settings: Arc<Settings>,
pub challenge_verifier: Arc<dyn ChallengeVerifier>,
}