feat: auth hardening + codebase quality sweep
Refresh tokens: RefreshToken entity, PostgresRefreshTokenRepository, login returns refresh token, POST /auth/refresh (rotation), POST /auth/logout, JWT expiry 24h→1h, configurable via with_expiry(). Route protection: require_auth middleware on protected routes, public routes split (register, login, refresh, sharing/access). Authorization: caller_id added to ReadAssetFileQuery, ReadDerivativeQuery, GetStackQuery, DeleteStackCommand with ownership checks. Admin-only gates on processing, storage, sidecar, duplicates handlers. Quality fixes: visibility filtering bypass in search(), unwrap panics in date parsing, DRY auth header parsing, centralized parsers module, email validation via email_address crate, value objects (Username, MimeType, RelativePath), domain events (UserCreated, UserDeleted, AlbumCreated, TagCreated, DuplicateDetected), postgres error mapping for constraint violations, OptionExt::or_not_found helper, in_memory_repo! macro, GetStackQuery moved to queries, album add_entry 200→201.
This commit is contained in:
@@ -54,6 +54,17 @@ impl Asset {
|
||||
}
|
||||
}
|
||||
|
||||
// --- AssetFilters ---
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AssetFilters {
|
||||
pub asset_type: Option<AssetType>,
|
||||
pub mime_type: Option<String>,
|
||||
pub date_from: Option<DateTimeStamp>,
|
||||
pub date_to: Option<DateTimeStamp>,
|
||||
pub is_processed: Option<bool>,
|
||||
}
|
||||
|
||||
// --- AssetMetadata ---
|
||||
|
||||
#[derive(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::entities::{
|
||||
Asset, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile, DuplicateGroup,
|
||||
MetadataSource,
|
||||
Asset, AssetFilters, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile,
|
||||
DuplicateGroup, MetadataSource,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::value_objects::{Checksum, StructuredData, SystemId};
|
||||
@@ -19,6 +19,13 @@ pub trait AssetRepository: Send + Sync {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError>;
|
||||
async fn search(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
filters: &AssetFilters,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError>;
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,13 @@ pub enum DomainError {
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub trait OptionExt<T> {
|
||||
fn or_not_found(self, entity: &str) -> Result<T, DomainError>;
|
||||
}
|
||||
|
||||
impl<T> OptionExt<T> for Option<T> {
|
||||
fn or_not_found(self, entity: &str) -> Result<T, DomainError> {
|
||||
self.ok_or_else(|| DomainError::NotFound(format!("{entity} not found")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,27 @@ pub enum DomainEvent {
|
||||
error: String,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
UserCreated {
|
||||
user_id: SystemId,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
UserDeleted {
|
||||
user_id: SystemId,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
AlbumCreated {
|
||||
album_id: SystemId,
|
||||
creator_id: SystemId,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
TagCreated {
|
||||
tag_id: SystemId,
|
||||
asset_id: SystemId,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
DuplicateDetected {
|
||||
group_id: SystemId,
|
||||
asset_ids: Vec<SystemId>,
|
||||
timestamp: DateTimeStamp,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ pub struct Email(String);
|
||||
impl Email {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into().trim().to_lowercase();
|
||||
if value.is_empty() || !value.contains('@') {
|
||||
return Err(DomainError::Validation("Invalid email address".to_string()));
|
||||
if !email_address::EmailAddress::is_valid(&value) {
|
||||
return Err(DomainError::Validation("Invalid email address".into()));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
26
crates/domain/src/common/value_objects/mime_type.rs
Normal file
26
crates/domain/src/common/value_objects/mime_type.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MimeType(String);
|
||||
|
||||
impl MimeType {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into();
|
||||
if !value.contains('/') || value.len() < 3 {
|
||||
return Err(DomainError::Validation(format!(
|
||||
"Invalid MIME type: {value}"
|
||||
)));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MimeType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,20 @@ mod checksum;
|
||||
mod date_time_stamp;
|
||||
mod email;
|
||||
pub mod filter_criteria;
|
||||
mod mime_type;
|
||||
mod password;
|
||||
mod relative_path;
|
||||
mod structured_data;
|
||||
mod system_id;
|
||||
mod username;
|
||||
|
||||
pub use checksum::Checksum;
|
||||
pub use date_time_stamp::DateTimeStamp;
|
||||
pub use email::Email;
|
||||
pub use filter_criteria::{FilterCondition, FilterCriteria, FilterOperator};
|
||||
pub use mime_type::MimeType;
|
||||
pub use password::PasswordHash;
|
||||
pub use relative_path::RelativePath;
|
||||
pub use structured_data::{MetadataValue, StructuredData};
|
||||
pub use system_id::SystemId;
|
||||
pub use username::Username;
|
||||
|
||||
31
crates/domain/src/common/value_objects/relative_path.rs
Normal file
31
crates/domain/src/common/value_objects/relative_path.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RelativePath(String);
|
||||
|
||||
impl RelativePath {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into();
|
||||
if value.is_empty() {
|
||||
return Err(DomainError::Validation("Path must not be empty".into()));
|
||||
}
|
||||
if value.contains("..") {
|
||||
return Err(DomainError::Validation("Path must not contain '..'".into()));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> &str {
|
||||
self.0.rsplit('/').next().unwrap_or(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RelativePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
34
crates/domain/src/common/value_objects/username.rs
Normal file
34
crates/domain/src/common/value_objects/username.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl Username {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||
let value = value.into();
|
||||
if value.len() < 2 || value.len() > 64 {
|
||||
return Err(DomainError::Validation(
|
||||
"Username must be 2-64 characters".into(),
|
||||
));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||
{
|
||||
return Err(DomainError::Validation(
|
||||
"Username may only contain alphanumeric, underscore, dash, or dot".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Username {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::value_objects::{Email, PasswordHash, SystemId};
|
||||
use crate::common::value_objects::{DateTimeStamp, Email, PasswordHash, SystemId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -141,6 +141,35 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
// --- RefreshToken ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshToken {
|
||||
pub token_id: SystemId,
|
||||
pub user_id: SystemId,
|
||||
pub token_hash: String,
|
||||
pub expires_at: DateTimeStamp,
|
||||
pub revoked: bool,
|
||||
pub created_at: DateTimeStamp,
|
||||
}
|
||||
|
||||
impl RefreshToken {
|
||||
pub fn new(user_id: SystemId, token_hash: String, expires_at: DateTimeStamp) -> Self {
|
||||
Self {
|
||||
token_id: SystemId::new(),
|
||||
user_id,
|
||||
token_hash,
|
||||
expires_at,
|
||||
revoked: false,
|
||||
created_at: DateTimeStamp::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.revoked && *self.expires_at.as_datetime() > Utc::now()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group ---
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::entities::{Group, Role, User};
|
||||
use super::entities::{Group, RefreshToken, Role, User};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::value_objects::{Email, PasswordHash, SystemId};
|
||||
use async_trait::async_trait;
|
||||
@@ -35,6 +35,16 @@ pub trait GroupRepository: Send + Sync {
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
// --- RefreshTokenRepository ---
|
||||
|
||||
#[async_trait]
|
||||
pub trait RefreshTokenRepository: Send + Sync {
|
||||
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError>;
|
||||
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError>;
|
||||
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -11,6 +11,13 @@ pub trait JobRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError>;
|
||||
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError>;
|
||||
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError>;
|
||||
async fn find_all(
|
||||
&self,
|
||||
status: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Job>, DomainError>;
|
||||
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError>;
|
||||
async fn save(&self, job: &Job) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user