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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user