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:
2026-05-31 22:26:02 +02:00
parent 84fb410316
commit c6f82090d2
71 changed files with 2311 additions and 563 deletions

View File

@@ -19,10 +19,14 @@ pub struct JwtTokenIssuer {
impl JwtTokenIssuer {
pub fn new(secret: &str) -> Self {
Self::with_expiry(secret, 1)
}
pub fn with_expiry(secret: &str, expiry_hours: i64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_hours: 24,
expiry_hours,
}
}
}

View File

@@ -54,6 +54,29 @@ pub enum EventPayload {
error: String,
timestamp: String,
},
UserCreated {
user_id: String,
timestamp: String,
},
UserDeleted {
user_id: String,
timestamp: String,
},
AlbumCreated {
album_id: String,
creator_id: String,
timestamp: String,
},
TagCreated {
tag_id: String,
asset_id: String,
timestamp: String,
},
DuplicateDetected {
group_id: String,
asset_ids: Vec<String>,
timestamp: String,
},
}
impl EventPayload {
@@ -69,6 +92,11 @@ impl EventPayload {
Self::JobEnqueued { .. } => "jobs.enqueued",
Self::JobCompleted { .. } => "jobs.completed",
Self::JobFailed { .. } => "jobs.failed",
Self::UserCreated { .. } => "users.created",
Self::UserDeleted { .. } => "users.deleted",
Self::AlbumCreated { .. } => "albums.created",
Self::TagCreated { .. } => "tags.created",
Self::DuplicateDetected { .. } => "duplicates.detected",
}
}
}
@@ -163,6 +191,41 @@ impl From<&DomainEvent> for EventPayload {
error: error.clone(),
timestamp: timestamp.to_string(),
},
DomainEvent::UserCreated { user_id, timestamp } => Self::UserCreated {
user_id: user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::UserDeleted { user_id, timestamp } => Self::UserDeleted {
user_id: user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::AlbumCreated {
album_id,
creator_id,
timestamp,
} => Self::AlbumCreated {
album_id: album_id.to_string(),
creator_id: creator_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::TagCreated {
tag_id,
asset_id,
timestamp,
} => Self::TagCreated {
tag_id: tag_id.to_string(),
asset_id: asset_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::DuplicateDetected {
group_id,
asset_ids,
timestamp,
} => Self::DuplicateDetected {
group_id: group_id.to_string(),
asset_ids: asset_ids.iter().map(|id| id.to_string()).collect(),
timestamp: timestamp.to_string(),
},
}
}
}
@@ -273,6 +336,44 @@ impl TryFrom<EventPayload> for DomainEvent {
error,
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::UserCreated { user_id, timestamp } => DomainEvent::UserCreated {
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::UserDeleted { user_id, timestamp } => DomainEvent::UserDeleted {
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::AlbumCreated {
album_id,
creator_id,
timestamp,
} => DomainEvent::AlbumCreated {
album_id: SystemId::from_uuid(parse_uuid(&album_id, "album_id")?),
creator_id: SystemId::from_uuid(parse_uuid(&creator_id, "creator_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::TagCreated {
tag_id,
asset_id,
timestamp,
} => DomainEvent::TagCreated {
tag_id: SystemId::from_uuid(parse_uuid(&tag_id, "tag_id")?),
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::DuplicateDetected {
group_id,
asset_ids,
timestamp,
} => DomainEvent::DuplicateDetected {
group_id: SystemId::from_uuid(parse_uuid(&group_id, "group_id")?),
asset_ids: asset_ids
.iter()
.map(|id| parse_uuid(id, "asset_id").map(SystemId::from_uuid))
.collect::<Result<Vec<_>, _>>()?,
timestamp: parse_timestamp(&timestamp)?,
},
})
}
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE asset_stacks (
stack_id UUID PRIMARY KEY,
stack_type TEXT NOT NULL,
primary_asset_id UUID NOT NULL REFERENCES assets(asset_id),
owner_user_id UUID NOT NULL,
members JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX idx_stacks_owner ON asset_stacks(owner_user_id);

View File

@@ -0,0 +1,10 @@
CREATE TABLE refresh_tokens (
token_id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -3,12 +3,16 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
entities::{
Asset, AssetMetadata, AssetType, DerivativeAsset, DerivativeProfile, DetectionMethod,
DuplicateCandidate, DuplicateGroup, DuplicateStatus, GenerationStatus, MetadataSource,
SourceReference,
Asset, AssetFilters, AssetMetadata, AssetStack, AssetStackMember, AssetType,
DerivativeAsset, DerivativeProfile, DetectionMethod, DuplicateCandidate, DuplicateGroup,
DuplicateStatus, GenerationStatus, MetadataSource, SourceReference, StackMemberRole,
StackType,
},
errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository, DerivativeRepository, DuplicateRepository},
ports::{
AssetMetadataRepository, AssetRepository, AssetStackRepository, DerivativeRepository,
DuplicateRepository,
},
value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId},
};
use uuid::Uuid;
@@ -123,6 +127,75 @@ impl AssetRepository for PostgresAssetRepository {
rows.into_iter().map(TryInto::try_into).collect()
}
async fn search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
let mut sql = String::from(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at
FROM assets WHERE owner_user_id = $1",
);
let mut param_idx = 2u32;
if filters.asset_type.is_some() {
sql.push_str(&format!(" AND asset_type = ${param_idx}"));
param_idx += 1;
}
if filters.mime_type.is_some() {
sql.push_str(&format!(" AND mime_type = ${param_idx}"));
param_idx += 1;
}
if filters.date_from.is_some() {
sql.push_str(&format!(" AND created_at >= ${param_idx}"));
param_idx += 1;
}
if filters.date_to.is_some() {
sql.push_str(&format!(" AND created_at <= ${param_idx}"));
param_idx += 1;
}
if filters.is_processed.is_some() {
sql.push_str(&format!(" AND is_processed = ${param_idx}"));
param_idx += 1;
}
sql.push_str(&format!(
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
param_idx,
param_idx + 1
));
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
if let Some(ref t) = filters.asset_type {
query = query.bind(asset_type_to_str(t));
}
if let Some(ref m) = filters.mime_type {
query = query.bind(m.as_str());
}
if let Some(ref d) = filters.date_from {
query = query.bind(d.as_datetime());
}
if let Some(ref d) = filters.date_to {
query = query.bind(d.as_datetime());
}
if let Some(p) = filters.is_processed {
query = query.bind(p);
}
let rows = query
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
@@ -597,3 +670,156 @@ impl DerivativeRepository for PostgresDerivativeRepository {
Ok(())
}
}
// ── AssetStack ──────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct StackRow {
stack_id: Uuid,
stack_type: String,
primary_asset_id: Uuid,
owner_user_id: Uuid,
members: serde_json::Value,
}
fn stack_type_from_str(s: &str) -> StackType {
match s {
"live_photo" => StackType::LivePhoto,
"format_pair" => StackType::FormatPair,
"burst_sequence" => StackType::BurstSequence,
"exposure_bracket" => StackType::ExposureBracket,
"manual_group" => StackType::ManualGroup,
_ => StackType::ManualGroup,
}
}
fn stack_type_to_str(t: &StackType) -> &'static str {
match t {
StackType::LivePhoto => "live_photo",
StackType::FormatPair => "format_pair",
StackType::BurstSequence => "burst_sequence",
StackType::ExposureBracket => "exposure_bracket",
StackType::ManualGroup => "manual_group",
}
}
fn member_role_from_str(s: &str) -> StackMemberRole {
match s {
"primary_display" => StackMemberRole::PrimaryDisplay,
"high_res_source" => StackMemberRole::HighResSource,
"motion_clip" => StackMemberRole::MotionClip,
"alternate_frame" => StackMemberRole::AlternateFrame,
_ => StackMemberRole::AlternateFrame,
}
}
fn member_role_to_str(r: &StackMemberRole) -> &'static str {
match r {
StackMemberRole::PrimaryDisplay => "primary_display",
StackMemberRole::HighResSource => "high_res_source",
StackMemberRole::MotionClip => "motion_clip",
StackMemberRole::AlternateFrame => "alternate_frame",
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct MemberJson {
asset_id: Uuid,
role: String,
sort_order: u32,
}
fn members_from_json(v: serde_json::Value) -> Vec<AssetStackMember> {
let arr: Vec<MemberJson> = serde_json::from_value(v).unwrap_or_default();
arr.into_iter()
.map(|m| AssetStackMember {
asset_id: SystemId::from_uuid(m.asset_id),
role: member_role_from_str(&m.role),
sort_order: m.sort_order,
})
.collect()
}
fn members_to_json(members: &[AssetStackMember]) -> serde_json::Value {
let arr: Vec<MemberJson> = members
.iter()
.map(|m| MemberJson {
asset_id: *m.asset_id.as_uuid(),
role: member_role_to_str(&m.role).to_string(),
sort_order: m.sort_order,
})
.collect();
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
}
impl From<StackRow> for AssetStack {
fn from(r: StackRow) -> Self {
Self {
stack_id: SystemId::from_uuid(r.stack_id),
stack_type: stack_type_from_str(&r.stack_type),
primary_asset_id: SystemId::from_uuid(r.primary_asset_id),
owner_user_id: SystemId::from_uuid(r.owner_user_id),
members: members_from_json(r.members),
}
}
}
pg_repo!(PostgresAssetStackRepository);
#[async_trait]
impl AssetStackRepository for PostgresAssetStackRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError> {
let row = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE stack_id = $1",
)
.bind(*id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
let rows = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE members @> $1::jsonb",
)
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO asset_stacks (stack_id, stack_type, primary_asset_id, owner_user_id, members)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stack_id) DO UPDATE SET
stack_type = EXCLUDED.stack_type,
primary_asset_id = EXCLUDED.primary_asset_id,
members = EXCLUDED.members",
)
.bind(*stack.stack_id.as_uuid())
.bind(stack_type_to_str(&stack.stack_type))
.bind(*stack.primary_asset_id.as_uuid())
.bind(*stack.owner_user_id.as_uuid())
.bind(members_to_json(&stack.members))
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM asset_stacks WHERE stack_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
}

View File

@@ -24,6 +24,16 @@ fn aggregate_id(event: &DomainEvent) -> Uuid {
DomainEvent::JobEnqueued { job_id, .. }
| DomainEvent::JobCompleted { job_id, .. }
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
DomainEvent::UserCreated { user_id, .. } | DomainEvent::UserDeleted { user_id, .. } => {
*user_id.as_uuid()
}
DomainEvent::AlbumCreated { album_id, .. } => *album_id.as_uuid(),
DomainEvent::TagCreated { tag_id, .. } => *tag_id.as_uuid(),
DomainEvent::DuplicateDetected { group_id, .. } => *group_id.as_uuid(),
}
}

View File

@@ -1,24 +1,33 @@
use domain::errors::DomainError;
/// Extension trait for converting `sqlx::Error` into `DomainError`.
pub trait MapDomainError<T> {
fn map_pg(self) -> Result<T, DomainError>;
}
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
fn map_pg(self) -> Result<T, DomainError> {
self.map_err(|e| DomainError::Internal(e.to_string()))
self.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") => {
DomainError::Conflict(
db_err
.constraint()
.map(|c| format!("Duplicate: {c}"))
.unwrap_or_else(|| "Duplicate entry".into()),
)
}
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23503") => {
DomainError::NotFound(
db_err
.constraint()
.map(|c| format!("Referenced entity not found: {c}"))
.unwrap_or_else(|| "Referenced entity not found".into()),
)
}
_ => DomainError::Internal(e.to_string()),
})
}
}
/// Generates a Postgres repository struct with a `PgPool` field and a `new` constructor.
///
/// ```ignore
/// pg_repo!(PostgresFooRepository);
/// // expands to:
/// // pub struct PostgresFooRepository { pool: PgPool }
/// // impl PostgresFooRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
/// ```
macro_rules! pg_repo {
($name:ident) => {
pub struct $name {

View File

@@ -2,9 +2,10 @@ use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
entities::RefreshToken,
errors::DomainError,
ports::UserRepository,
value_objects::{Email, PasswordHash, SystemId},
ports::{RefreshTokenRepository, UserRepository},
value_objects::{DateTimeStamp, Email, PasswordHash, SystemId},
};
use uuid::Uuid;
@@ -109,3 +110,82 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
}
// --- PostgresRefreshTokenRepository ---
#[derive(sqlx::FromRow)]
struct RefreshTokenRow {
token_id: Uuid,
user_id: Uuid,
token_hash: String,
expires_at: DateTime<Utc>,
revoked: bool,
created_at: DateTime<Utc>,
}
impl From<RefreshTokenRow> for RefreshToken {
fn from(r: RefreshTokenRow) -> Self {
Self {
token_id: SystemId::from_uuid(r.token_id),
user_id: SystemId::from_uuid(r.user_id),
token_hash: r.token_hash,
expires_at: DateTimeStamp::from_datetime(r.expires_at),
revoked: r.revoked,
created_at: DateTimeStamp::from_datetime(r.created_at),
}
}
}
pg_repo!(PostgresRefreshTokenRepository);
#[async_trait]
impl RefreshTokenRepository for PostgresRefreshTokenRepository {
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_tokens (token_id, user_id, token_hash, expires_at, revoked, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (token_id) DO UPDATE SET revoked = EXCLUDED.revoked",
)
.bind(*token.token_id.as_uuid())
.bind(*token.user_id.as_uuid())
.bind(&token.token_hash)
.bind(*token.expires_at.as_datetime())
.bind(token.revoked)
.bind(*token.created_at.as_datetime())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
let row = sqlx::query_as::<_, RefreshTokenRow>(
"SELECT token_id, user_id, token_hash, expires_at, revoked, created_at
FROM refresh_tokens WHERE token_hash = $1",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_tokens WHERE user_id = $1")
.bind(*user_id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_tokens WHERE token_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
}

View File

@@ -154,6 +154,59 @@ impl JobRepository for PostgresJobRepository {
Ok(row.map(Into::into))
}
async fn find_all(
&self,
status: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Vec<Job>, DomainError> {
let rows = match status {
Some(s) => sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
payload, result_data, retry_count, max_retries, created_at,
started_at, completed_at, error_message
FROM jobs WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3",
)
.bind(s)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?,
None => sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
payload, result_data, retry_count, max_retries, created_at,
started_at, completed_at, error_message
FROM jobs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2",
)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?,
};
Ok(rows.into_iter().map(Into::into).collect())
}
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError> {
let count: (i64,) = match status {
Some(s) => sqlx::query_as("SELECT COUNT(*) FROM jobs WHERE status = $1")
.bind(s)
.fetch_one(&self.pool)
.await
.map_pg()?,
None => sqlx::query_as("SELECT COUNT(*) FROM jobs")
.fetch_one(&self.pool)
.await
.map_pg()?,
};
Ok(count.0 as u64)
}
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> {
let rows = sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,