a47ae83
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<Uuid>,
pub kind: DirKind,
pub name: String,
pub parent_dir_id: Option<Uuid>,
pub color: Option<String>,
pub position: i64,
pub icon: Option<String>,
pub topic: Option<String>,
pub purpose: Option<String>,
pub is_archived: bool,
pub archived_at: Option<OffsetDateTime>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub created_by_uid: i64,
}
impl From<dirs::Model> 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<Uuid>,
pub position: Option<i64>,
pub topic: Option<String>,
pub purpose: Option<String>,
pub icon: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateDir {
pub name: Option<String>,
pub topic: Option<Option<String>>,
pub icon: Option<Option<String>>,
pub position: Option<i64>,
pub parent_dir_id: Option<Option<Uuid>>,
}
pub async fn create_folder(
tx: &impl ConnectionTrait,
actor: i64,
mut cmd: NewDir,
) -> Result<DirDto, Error> {
cmd.kind = DirKind::Dir;
create(tx, actor, cmd).await
}
pub async fn create_object(
tx: &impl ConnectionTrait,
actor: i64,
cmd: NewDir,
) -> Result<DirDto, Error> {
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<DirDto, Error> {
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<DirDto, Error> {
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<DirDto, Error> {
dirs::Entity::find_by_id(id)
.one(tx)
.await?
.map(DirDto::from)
.ok_or(Error::NotFound)
}
fn validate_name(name: &str) -> Result<String, Error> {
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<i64, Error> {
if position < 0 {
return Err(Error::InvalidInput(
"position must be non-negative".to_owned(),
));
}
Ok(position)
}
fn normalize_optional(value: Option<String>) -> Option<String> {
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<Uuid>,
) -> 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<Uuid>,
) -> Result<i64, Error> {
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<OffsetDateTime>,
) -> 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<bool, Error> {
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(())
}