use models::dir::dir_overwrite_users; use models::dir::dirs::{self, DirKind}; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; /// Bit used by `allow_mask` / `deny_mask` for directory visibility. pub const PERM_VISIBLE: i64 = 1 << 0; const MAX_NAME_LEN: usize = 255; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("{0}")] InvalidInput(String), #[error("dir not found")] NotFound, #[error(transparent)] Database(#[from] sea_orm::DbErr), } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DirDto { pub id: Uuid, pub space_id: Option, pub kind: DirKind, pub name: String, pub parent_dir_id: Option, pub color: Option, pub position: i64, pub icon: Option, pub topic: Option, pub purpose: Option, pub is_archived: bool, pub archived_at: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, pub created_by_uid: i64, } impl From for DirDto { fn from(m: dirs::Model) -> Self { Self { id: m.id, space_id: m.space_id, kind: m.kind, name: m.name, parent_dir_id: m.parent_dir_id, color: m.color, position: m.position, icon: m.icon, topic: m.topic, purpose: m.purpose, is_archived: m.is_archived, archived_at: m.archived_at, created_at: m.created_at, updated_at: m.updated_at, created_by_uid: m.created_by_uid, } } } #[derive(Debug, Clone)] pub struct NewDir { pub space_id: Uuid, pub kind: DirKind, pub name: String, pub parent_dir_id: Option, pub position: Option, pub topic: Option, pub purpose: Option, pub icon: Option, pub color: Option, } #[derive(Debug, Clone, Default)] pub struct UpdateDir { pub name: Option, pub topic: Option>, pub icon: Option>, pub position: Option, pub parent_dir_id: Option>, } pub async fn create_folder( tx: &impl ConnectionTrait, actor: i64, mut cmd: NewDir, ) -> Result { cmd.kind = DirKind::Dir; create(tx, actor, cmd).await } pub async fn create_object( tx: &impl ConnectionTrait, actor: i64, cmd: NewDir, ) -> Result { if cmd.kind.is_folder() { return Err(Error::InvalidInput( "business dirs must declare a concrete kind".to_owned(), )); } create(tx, actor, cmd).await } async fn create(tx: &impl ConnectionTrait, actor: i64, cmd: NewDir) -> Result { let name = validate_name(&cmd.name)?; if let Some(parent) = cmd.parent_dir_id { ensure_parent(tx, cmd.space_id, parent, None).await?; } let position = match cmd.position { Some(position) => validate_position(position)?, None => next_position(tx, cmd.space_id, cmd.parent_dir_id).await?, }; let now = OffsetDateTime::now_utc(); let created = dirs::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(Some(cmd.space_id)), name: Set(name), kind: Set(cmd.kind), parent_dir_id: Set(cmd.parent_dir_id), color: Set(cmd.color), position: Set(position), icon: Set(cmd.icon), topic: Set(cmd.topic), purpose: Set(cmd.purpose), is_archived: Set(false), archived_at: Set(None), archived_by: Set(None), created_at: Set(now), updated_at: Set(now), created_by_uid: Set(actor), } .insert(tx) .await?; let dto = DirDto::from(created); emit(tx, "dir.create", actor, &dto).await?; Ok(dto) } pub async fn update_object( tx: &impl ConnectionTrait, actor: i64, dir_id: Uuid, cmd: UpdateDir, ) -> Result { let existing = dirs::Entity::find_by_id(dir_id) .one(tx) .await? .ok_or(Error::NotFound)?; if existing.is_archived { return Err(Error::InvalidInput("dir is archived".to_owned())); } let space_id = existing .space_id .ok_or_else(|| Error::InvalidInput("dir has no space".to_owned()))?; let next_parent = match cmd.parent_dir_id { Some(None) => None, Some(Some(parent)) => { ensure_parent(tx, space_id, parent, Some(dir_id)).await?; Some(parent) } None => existing.parent_dir_id, }; let parent_changed = next_parent != existing.parent_dir_id; let position = match cmd.position { Some(position) => validate_position(position)?, None if parent_changed => next_position(tx, space_id, next_parent).await?, None => existing.position, }; let mut am: dirs::ActiveModel = existing.into(); if let Some(name) = cmd.name { am.name = Set(validate_name(&name)?); } if let Some(topic) = cmd.topic { am.topic = Set(normalize_optional(topic)); } if let Some(icon) = cmd.icon { am.icon = Set(normalize_optional(icon)); } am.parent_dir_id = Set(next_parent); am.position = Set(position); am.updated_at = Set(OffsetDateTime::now_utc()); let updated = am.update(tx).await?; let dto = DirDto::from(updated); emit(tx, "dir.update", actor, &dto).await?; Ok(dto) } pub async fn archive(tx: &impl ConnectionTrait, actor: i64, dir_id: Uuid) -> Result<(), Error> { let existing = dirs::Entity::find_by_id(dir_id) .one(tx) .await? .ok_or(Error::NotFound)?; if existing.is_archived { return Ok(()); } let now = OffsetDateTime::now_utc(); let mut am: dirs::ActiveModel = existing.into(); am.is_archived = Set(true); am.archived_at = Set(Some(now)); am.archived_by = Set(Some(actor)); am.updated_at = Set(now); let updated = am.update(tx).await?; let dto = DirDto::from(updated); emit(tx, "dir.archive", actor, &dto).await?; Ok(()) } pub async fn dir(tx: &impl ConnectionTrait, id: Uuid) -> Result { dirs::Entity::find_by_id(id) .one(tx) .await? .map(DirDto::from) .ok_or(Error::NotFound) } fn validate_name(name: &str) -> Result { let name = name.trim(); if name.is_empty() || name.chars().count() > MAX_NAME_LEN { return Err(Error::InvalidInput( "dir name is empty or too long".to_owned(), )); } Ok(name.to_owned()) } fn validate_position(position: i64) -> Result { if position < 0 { return Err(Error::InvalidInput( "position must be non-negative".to_owned(), )); } Ok(position) } fn normalize_optional(value: Option) -> Option { value .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) } async fn ensure_parent( tx: &impl ConnectionTrait, space_id: Uuid, parent: Uuid, moving: Option, ) -> Result<(), Error> { if moving == Some(parent) { return Err(Error::InvalidInput( "cannot move a dir into itself".to_owned(), )); } let node = dirs::Entity::find_by_id(parent) .filter(dirs::Column::SpaceId.eq(space_id)) .one(tx) .await? .ok_or(Error::NotFound)?; if !node.kind.is_folder() || node.is_archived { return Err(Error::InvalidInput( "parent dir must be an active folder".to_owned(), )); } if let Some(moving) = moving { let mut cursor = node.parent_dir_id; let mut guard = 0; while let Some(current) = cursor { if current == moving { return Err(Error::InvalidInput( "cannot move a dir under its own descendant".to_owned(), )); } guard += 1; if guard > 1024 { break; } cursor = dirs::Entity::find_by_id(current) .one(tx) .await? .and_then(|row| row.parent_dir_id); } } Ok(()) } async fn next_position( tx: &impl ConnectionTrait, space_id: Uuid, parent_dir_id: Option, ) -> Result { let last = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space_id)) .filter(dirs::Column::ParentDirId.eq(parent_dir_id)) .order_by_desc(dirs::Column::Position) .lock(sea_orm::sea_query::LockType::Update) .one(tx) .await?; Ok(last.map(|node| node.position + 1).unwrap_or(0)) } async fn emit( tx: &impl ConnectionTrait, kind: &str, actor: i64, dir: &DirDto, ) -> Result<(), Error> { events::emit( tx, kind, events::Labels::new() .with(events::label::DIR_ID, dir.id) .with(events::label::ACTOR_USER_ID, actor), dir, ) .await?; Ok(()) } pub async fn set_user_overwrite( tx: &impl ConnectionTrait, dir_id: Uuid, user_id: i64, allow_mask: i64, deny_mask: i64, expires_at: Option, ) -> Result<(), Error> { if allow_mask < 0 || deny_mask < 0 { return Err(Error::InvalidInput( "overwrite masks must be non-negative".to_owned(), )); } dirs::Entity::find_by_id(dir_id) .one(tx) .await? .ok_or(Error::NotFound)?; dir_overwrite_users::Entity::insert(dir_overwrite_users::ActiveModel { dir_id: Set(dir_id), user_id: Set(user_id), allow_mask: Set(allow_mask), deny_mask: Set(deny_mask), expires_at: Set(expires_at), }) .on_conflict( sea_orm::sea_query::OnConflict::columns([ dir_overwrite_users::Column::DirId, dir_overwrite_users::Column::UserId, ]) .update_columns([ dir_overwrite_users::Column::AllowMask, dir_overwrite_users::Column::DenyMask, dir_overwrite_users::Column::ExpiresAt, ]) .to_owned(), ) .exec_without_returning(tx) .await?; Ok(()) } pub async fn remove_user_overwrite( tx: &impl ConnectionTrait, dir_id: Uuid, user_id: i64, ) -> Result { let result = dir_overwrite_users::Entity::delete_many() .filter(dir_overwrite_users::Column::DirId.eq(dir_id)) .filter(dir_overwrite_users::Column::UserId.eq(user_id)) .exec(tx) .await?; Ok(result.rows_affected > 0) } pub async fn clear_user_overwrites(tx: &impl ConnectionTrait, dir_id: Uuid) -> Result<(), Error> { dir_overwrite_users::Entity::delete_many() .filter(dir_overwrite_users::Column::DirId.eq(dir_id)) .exec(tx) .await?; Ok(()) }