use std::collections::{HashMap, HashSet}; use models::dir::dir_overwrite_group; use models::dir::dir_overwrite_users; use models::dir::dirs::{self, DirKind}; use models::space::space_groups; use models::space::space_member_groups; use models::space::space_members::{self, MemberStatus}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::services::spaces::UserDto; use crate::share::authz::{ MANAGE_CHANNELS, MANAGE_OVERWRITES, effective_permissions, require_perm, }; use crate::share::lookup::lookup_space_by_name; pub const MAX_DIR_NODE_NAME: usize = 255; pub const PERM_DIR_VISIBLE: i64 = 1 << 0; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DirNodeDto { pub id: Uuid, pub name: String, pub kind: DirKind, 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_id: i64, #[schema(no_recursion)] pub children: Vec, } impl From for DirNodeDto { fn from(m: dirs::Model) -> Self { Self { id: m.id, name: m.name, kind: m.kind, 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_id: m.created_by_uid, children: Vec::new(), } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateDirCmd { pub name: String, #[serde(default)] pub parent_dir_id: Option, #[serde(default)] pub position: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateDirCmd { pub name: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct MoveDirCmd { #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub parent_dir_id: Option>, #[serde(default)] pub position: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RepairDirResp { pub scanned: i64, pub fixed: i64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct GroupOverwriteDto { pub dir_id: Uuid, pub group_id: Uuid, pub allow_mask: i64, pub deny_mask: i64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserOverwriteDto { pub dir_id: Uuid, pub user_id: i64, pub user: Option, pub allow_mask: i64, pub deny_mask: i64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SetOverwriteCmd { pub allow_mask: i64, pub deny_mask: i64, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct DirPath { pub space_name: String, pub dir_id: Uuid, } fn resolve_node_visible( caller_uid: i64, my_group_ids: &HashSet, group_ows: &[dir_overwrite_group::Model], user_ows: &[dir_overwrite_users::Model], ) -> bool { if group_ows.is_empty() && user_ows.is_empty() { return true; } if let Some(uo) = user_ows.iter().find(|u| u.user_id == caller_uid) { if (uo.deny_mask & PERM_DIR_VISIBLE) != 0 { return false; } if (uo.allow_mask & PERM_DIR_VISIBLE) != 0 { return true; } } let matching: Vec<&dir_overwrite_group::Model> = group_ows .iter() .filter(|g| my_group_ids.contains(&g.group_id)) .collect(); if matching .iter() .any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0) { return false; } if matching .iter() .any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0) { return true; } true } impl SpaceService { async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result { dirs::Entity::find_by_id(dir_id) .filter(dirs::Column::SpaceId.eq(space_id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound) } #[tracing::instrument(name = "space.create_dir_node", skip_all, fields(caller = %caller, space = %space_name))] pub async fn create_dir_node( &self, caller: i64, space_name: &str, cmd: CreateDirCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME { return Err(SpaceError::BadRequest( "dir name is empty or too long".to_string(), )); } if let Some(p) = cmd.parent_dir_id { let parent_node = self.dir_in_space(space.id, p).await?; if parent_node.kind != DirKind::Dir { return Err(SpaceError::BadRequest( "parent node must be a dir".to_string(), )); } } let position = match cmd.position { Some(pos) => { if pos < 0 { return Err(SpaceError::BadRequest( "position must be non-negative".to_string(), )); } pos } None => { let max_node = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id)) .order_by_desc(dirs::Column::Position) .one(self.db().read()) .await?; max_node.map(|n| n.position + 1).unwrap_or(0) } }; let now = OffsetDateTime::now_utc(); let row = dirs::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(Some(space.id)), name: Set(name.to_string()), kind: Set(DirKind::Dir), parent_dir_id: Set(cmd.parent_dir_id), color: Set(None), position: Set(position), icon: Set(None), topic: Set(None), purpose: Set(None), is_archived: Set(false), archived_at: Set(None), archived_by: Set(None), created_at: Set(now), updated_at: Set(now), created_by_uid: Set(caller), }; let inserted = row.insert(self.db().write()).await?; Ok(DirNodeDto::from(inserted)) } pub async fn get_space_dir_tree( &self, caller: i64, space_name: &str, parent_id: Option, ) -> Result, SpaceError> { let space = lookup_space_by_name(self, space_name).await?; let perms = effective_permissions(&self.state, &space, caller).await?; let can_manage = perms.has(MANAGE_CHANNELS); if parent_id.is_none() { let total_nodes = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .count(self.db().read()) .await? as u64; let max_nodes = self.config().space.max_dir_nodes; if total_nodes > max_nodes { return Err(SpaceError::BadRequest(format!( "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; query with parent_id to load one level" ))); } } let mut sel = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .order_by_asc(dirs::Column::Position); if let Some(pid) = parent_id { sel = sel.filter(dirs::Column::ParentDirId.eq(Some(pid))); } let nodes = sel.all(self.db().read()).await?; if nodes.is_empty() { return Ok(Vec::new()); } let node_ids: Vec = nodes.iter().map(|n| n.id).collect(); let group_ows = dir_overwrite_group::Entity::find() .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone())) .all(self.db().read()) .await?; let user_ows = dir_overwrite_users::Entity::find() .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone())) .all(self.db().read()) .await?; let my_member_groups = space_member_groups::Entity::find() .filter(space_member_groups::Column::SpaceId.eq(space.id)) .filter(space_member_groups::Column::UserId.eq(caller)) .all(self.db().read()) .await?; let my_group_ids: HashSet = my_member_groups.into_iter().map(|mg| mg.group_id).collect(); let mut group_ow_map: HashMap> = HashMap::new(); for go in group_ows { group_ow_map.entry(go.dir_id).or_default().push(go); } let mut user_ow_map: HashMap> = HashMap::new(); for uo in user_ows { user_ow_map.entry(uo.dir_id).or_default().push(uo); } let mut direct_visible: HashMap = HashMap::new(); let mut parent_map: HashMap> = HashMap::new(); let mut node_map: HashMap = HashMap::new(); for n in &nodes { parent_map.insert(n.id, n.parent_dir_id); node_map.insert(n.id, n.clone()); if can_manage { direct_visible.insert(n.id, true); continue; } let group_ows = group_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]); let user_ows = user_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]); direct_visible.insert( n.id, resolve_node_visible(caller, &my_group_ids, group_ows, user_ows), ); } fn is_truly_visible( id: Uuid, parent_map: &HashMap>, direct_visible: &HashMap, memo: &mut HashMap, visiting: &mut HashSet, ) -> bool { if let Some(&vis) = memo.get(&id) { return vis; } if visiting.contains(&id) { return false; } if !direct_visible.get(&id).copied().unwrap_or(false) { memo.insert(id, false); return false; } visiting.insert(id); let parent_opt = parent_map.get(&id).copied().flatten(); let parent_vis = match parent_opt { Some(pid) if parent_map.contains_key(&pid) => { is_truly_visible(pid, parent_map, direct_visible, memo, visiting) } _ => true, }; visiting.remove(&id); memo.insert(id, parent_vis); parent_vis } let mut memo = HashMap::new(); let mut truly_visible_set = HashSet::new(); for &nid in &node_ids { let mut visiting = HashSet::new(); if is_truly_visible(nid, &parent_map, &direct_visible, &mut memo, &mut visiting) { truly_visible_set.insert(nid); } } let mut children_map: HashMap, Vec> = HashMap::new(); for n in nodes { if !truly_visible_set.contains(&n.id) { continue; } let parent_id = match n.parent_dir_id { Some(p) if truly_visible_set.contains(&p) => Some(p), _ => None, }; children_map .entry(parent_id) .or_default() .push(DirNodeDto::from(n)); } fn assemble_tree( parent_id: Option, children_map: &mut HashMap, Vec>, ) -> Vec { let mut list = children_map.remove(&parent_id).unwrap_or_default(); list.sort_by(|a, b| { a.position .cmp(&b.position) .then_with(|| a.created_at.cmp(&b.created_at)) }); for item in &mut list { item.children = assemble_tree(Some(item.id), children_map); } list } let forest = assemble_tree(None, &mut children_map); Ok(forest) } #[tracing::instrument(name = "space.update_dir_node", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id))] pub async fn update_dir_node( &self, caller: i64, space_name: &str, dir_id: Uuid, cmd: UpdateDirCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; let node: dirs::Model = self.dir_in_space(space.id, dir_id).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME { return Err(SpaceError::BadRequest( "dir name is empty or too long".to_string(), )); } let now = OffsetDateTime::now_utc(); let mut am: dirs::ActiveModel = node.into(); am.name = Set(name.to_string()); am.updated_at = Set(now); let updated = am.update(self.db().write()).await?; Ok(DirNodeDto::from(updated)) } pub async fn move_dir_node( &self, caller: i64, space_name: &str, dir_id: Uuid, cmd: MoveDirCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; let node = self.dir_in_space(space.id, dir_id).await?; let new_parent_id = match cmd.parent_dir_id { None => node.parent_dir_id, Some(None) => None, Some(Some(p)) => { if p == dir_id { return Err(SpaceError::BadRequest( "cannot set parent to self".to_string(), )); } let parent_node = self.dir_in_space(space.id, p).await?; if parent_node.kind != DirKind::Dir { return Err(SpaceError::BadRequest( "parent node must be a dir".to_string(), )); } let mut curr = parent_node.parent_dir_id; while let Some(curr_p) = curr { if curr_p == dir_id { return Err(SpaceError::BadRequest( "cannot move node into its own descendant".to_string(), )); } let next = dirs::Entity::find_by_id(curr_p) .filter(dirs::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await?; curr = next.and_then(|n| n.parent_dir_id); } Some(p) } }; let new_position = match cmd.position { Some(pos) => { if pos < 0 { return Err(SpaceError::BadRequest( "position must be non-negative".to_string(), )); } pos } None if new_parent_id != node.parent_dir_id => { let max_node = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .filter(dirs::Column::ParentDirId.eq(new_parent_id)) .order_by_desc(dirs::Column::Position) .one(self.db().read()) .await?; max_node.map(|n| n.position + 1).unwrap_or(0) } None => node.position, }; let now = OffsetDateTime::now_utc(); let mut am: dirs::ActiveModel = node.into(); am.parent_dir_id = Set(new_parent_id); am.position = Set(new_position); am.updated_at = Set(now); let updated = am.update(self.db().write()).await?; Ok(DirNodeDto::from(updated)) } pub async fn repair_dir_parents( &self, caller: i64, space_name: &str, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; if space.owner_id != caller { return Err(SpaceError::Forbidden); } let nodes = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .all(self.db().read()) .await?; let scanned = nodes.len() as i64; let mut parent_map: HashMap> = HashMap::new(); for n in &nodes { parent_map.insert(n.id, n.parent_dir_id); } let mut fixed_nodes: HashSet = HashSet::new(); for n in &nodes { if let Some(p) = n.parent_dir_id && (p == n.id || !parent_map.contains_key(&p)) { fixed_nodes.insert(n.id); parent_map.insert(n.id, None); } } let mut state: HashMap = HashMap::new(); let keys: Vec = parent_map.keys().copied().collect(); for nid in keys { if state.get(&nid).copied().unwrap_or(0) != 0 { continue; } let mut curr = Some(nid); let mut path = Vec::new(); while let Some(c) = curr { let st = state.get(&c).copied().unwrap_or(0); if st == 1 { fixed_nodes.insert(c); parent_map.insert(c, None); break; } else if st == 2 { break; } state.insert(c, 1); path.push(c); curr = parent_map.get(&c).copied().flatten(); } for p in path { state.insert(p, 2); } } let now = OffsetDateTime::now_utc(); let fixed = fixed_nodes.len() as i64; if !fixed_nodes.is_empty() { self.db() .primary() .transaction::<_, (), SpaceError>(|txn| { Box::pin(async move { for fid in fixed_nodes { let node = dirs::Entity::find_by_id(fid).one(txn).await?; if let Some(n) = node { let mut am: dirs::ActiveModel = n.into(); am.parent_dir_id = Set(None); am.updated_at = Set(now); am.update(txn).await?; } } Ok(()) }) }) .await?; } Ok(RepairDirResp { scanned, fixed }) } pub async fn list_dir_group_overwrites( &self, caller: i64, space_name: &str, dir_id: Uuid, ) -> Result, SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; let rows = dir_overwrite_group::Entity::find() .filter(dir_overwrite_group::Column::DirId.eq(dir_id)) .all(self.db().read()) .await?; Ok(rows .into_iter() .map(|r| GroupOverwriteDto { dir_id: r.dir_id, group_id: r.group_id, allow_mask: r.allow_mask, deny_mask: r.deny_mask, }) .collect()) } #[tracing::instrument(name = "space.set_dir_group_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, group_id = %group_id))] pub async fn set_dir_group_overwrite( &self, caller: i64, space_name: &str, dir_id: Uuid, group_id: Uuid, cmd: SetOverwriteCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; space_groups::Entity::find_by_id(group_id) .filter(space_groups::Column::SpaceId.eq(space.id)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; if cmd.allow_mask < 0 || cmd.deny_mask < 0 { return Err(SpaceError::BadRequest( "masks must be non-negative".to_string(), )); } let existing = dir_overwrite_group::Entity::find_by_id((dir_id, group_id)) .one(self.db().read()) .await?; if let Some(row) = existing { let mut am: dir_overwrite_group::ActiveModel = row.into(); am.allow_mask = Set(cmd.allow_mask); am.deny_mask = Set(cmd.deny_mask); let updated = am.update(self.db().write()).await?; Ok(GroupOverwriteDto { dir_id: updated.dir_id, group_id: updated.group_id, allow_mask: updated.allow_mask, deny_mask: updated.deny_mask, }) } else { let am = dir_overwrite_group::ActiveModel { dir_id: Set(dir_id), group_id: Set(group_id), allow_mask: Set(cmd.allow_mask), deny_mask: Set(cmd.deny_mask), }; let inserted = am.insert(self.db().write()).await?; Ok(GroupOverwriteDto { dir_id: inserted.dir_id, group_id: inserted.group_id, allow_mask: inserted.allow_mask, deny_mask: inserted.deny_mask, }) } } #[tracing::instrument(name = "space.remove_dir_group_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, group_id = %group_id))] pub async fn remove_dir_group_overwrite( &self, caller: i64, space_name: &str, dir_id: Uuid, group_id: Uuid, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; let res = dir_overwrite_group::Entity::delete_many() .filter(dir_overwrite_group::Column::DirId.eq(dir_id)) .filter(dir_overwrite_group::Column::GroupId.eq(group_id)) .exec(self.db().write()) .await?; if res.rows_affected == 0 { return Err(SpaceError::NotFound); } Ok(()) } pub async fn list_dir_user_overwrites( &self, caller: i64, space_name: &str, dir_id: Uuid, ) -> Result, SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; let rows = dir_overwrite_users::Entity::find() .filter(dir_overwrite_users::Column::DirId.eq(dir_id)) .all(self.db().read()) .await?; let user_ids: Vec = rows.iter().map(|r| r.user_id).collect(); let user_map: HashMap = self.load_user_briefs(&user_ids).await?; Ok(rows .into_iter() .map(|r| { let user_brief = user_map.get(&r.user_id).cloned(); UserOverwriteDto { dir_id: r.dir_id, user_id: r.user_id, user: user_brief, allow_mask: r.allow_mask, deny_mask: r.deny_mask, } }) .collect()) } #[tracing::instrument(name = "space.set_dir_user_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, user_id = %user_id))] pub async fn set_dir_user_overwrite( &self, caller: i64, space_name: &str, dir_id: Uuid, user_id: i64, cmd: SetOverwriteCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; if space.owner_id != user_id { space_members::Entity::find_by_id((space.id, user_id)) .one(self.db().read()) .await? .filter(|m| m.status == MemberStatus::Active) .ok_or(SpaceError::BadRequest( "target user is not an active member of this space".to_string(), ))?; } if cmd.allow_mask < 0 || cmd.deny_mask < 0 { return Err(SpaceError::BadRequest( "masks must be non-negative".to_string(), )); } let existing = dir_overwrite_users::Entity::find_by_id((dir_id, user_id)) .one(self.db().read()) .await?; let (final_allow, final_deny) = if let Some(row) = existing { let mut am: dir_overwrite_users::ActiveModel = row.into(); am.allow_mask = Set(cmd.allow_mask); am.deny_mask = Set(cmd.deny_mask); let updated = am.update(self.db().write()).await?; (updated.allow_mask, updated.deny_mask) } else { let am = dir_overwrite_users::ActiveModel { dir_id: Set(dir_id), user_id: Set(user_id), allow_mask: Set(cmd.allow_mask), deny_mask: Set(cmd.deny_mask), }; let inserted = am.insert(self.db().write()).await?; (inserted.allow_mask, inserted.deny_mask) }; let user_map: HashMap = self.load_user_briefs(&[user_id]).await?; let user_brief = user_map.get(&user_id).cloned(); Ok(UserOverwriteDto { dir_id, user_id, user: user_brief, allow_mask: final_allow, deny_mask: final_deny, }) } #[tracing::instrument(name = "space.remove_dir_user_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, user_id = %user_id))] pub async fn remove_dir_user_overwrite( &self, caller: i64, space_name: &str, dir_id: Uuid, user_id: i64, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_OVERWRITES).await?; self.dir_in_space(space.id, dir_id).await?; let res = 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(self.db().write()) .await?; if res.rows_affected == 0 { return Err(SpaceError::NotFound); } Ok(()) } }