Skip to content
Clone
ef0b83a
346 lines 11.3 KB Rust
use models::room::room_members;
use models::room::rooms::{self, RoomKind};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QuerySelect, Set,
TransactionTrait,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::authority;
use crate::errors::SpaceError;
use crate::services::service::SpaceService;
use crate::share::authz::{MANAGE_CHANNELS, require_perm};
use crate::share::lookup;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateRoomCmd {
pub name: String,
#[serde(default)]
pub kind: RoomKind,
#[serde(default)]
pub topic: Option<String>,
#[serde(default)]
pub is_private: bool,
#[serde(default)]
pub nsfw: bool,
#[serde(default)]
pub user_limit: Option<i32>,
#[serde(default)]
pub rate_limit_per_user: Option<i32>,
#[serde(default)]
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RoomDto {
pub id: Uuid,
pub space_id: Option<Uuid>,
pub kind: RoomKind,
pub name: String,
pub topic: Option<String>,
pub position: i64,
pub parent_id: Option<Uuid>,
pub owner_id: i64,
pub is_private: bool,
pub nsfw: bool,
pub user_limit: Option<i32>,
pub rate_limit_per_user: Option<i32>,
pub is_active: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl From<rooms::Model> for RoomDto {
fn from(r: rooms::Model) -> Self {
Self {
id: r.id,
space_id: r.space_id,
kind: r.kind,
name: r.name,
topic: r.topic,
position: r.position,
parent_id: r.parent_id,
owner_id: r.owner_id,
is_private: r.is_private,
nsfw: r.nsfw,
user_limit: r.user_limit,
rate_limit_per_user: r.rate_limit_per_user,
is_active: r.is_active,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
fn is_valid_lower_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
impl SpaceService {
#[tracing::instrument(name = "space.create_room", skip_all, fields(caller = %caller, space = %space_name))]
pub async fn create_room(
&self,
caller: i64,
space_name: &str,
cmd: CreateRoomCmd,
) -> Result<RoomDto, SpaceError> {
if !self
.state
.redis
.quota_allow(
&format!("quota:room_create:{caller}"),
self.config().quota.room_create_per_day,
86_400,
)
.await
{
return Err(SpaceError::RateLimited(
"room creation quota exceeded, retry tomorrow".to_string(),
));
}
let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
if !authority::space_is_active(&space) {
return Err(SpaceError::NotFound);
}
if !matches!(
cmd.kind,
RoomKind::GuildRoom
| RoomKind::Text
| RoomKind::Category
| RoomKind::News
| RoomKind::Voice
| RoomKind::Stage
) {
return Err(SpaceError::BadRequest(
"unsupported room kind: only guild_room, text, category, news, voice and stage can be created"
.to_string(),
));
}
let name = cmd.name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err(SpaceError::BadRequest(
"room name is empty or too long".to_string(),
));
}
let lower = name.to_lowercase();
if !is_valid_lower_name(&lower) {
return Err(SpaceError::BadRequest(
"invalid room name: must contain only lowercase letters, digits, and hyphens"
.to_string(),
));
}
let existing = rooms::Entity::find()
.filter(rooms::Column::SpaceId.eq(space.id))
.filter(rooms::Column::LowerName.eq(&lower))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(SpaceError::Conflict(format!(
"room name {lower} already exists in this space"
)));
}
let now = OffsetDateTime::now_utc();
let room_id = Uuid::now_v7();
let max_pos: i64 = rooms::Entity::find()
.filter(rooms::Column::SpaceId.eq(space.id))
.select_only()
.column_as(
sea_orm::sea_query::Expr::col(rooms::Column::Position).max(),
"max_pos",
)
.into_tuple::<Option<i64>>()
.one(self.db().read())
.await?
.flatten()
.unwrap_or(-1);
let position = max_pos + 1;
let room_row = rooms::ActiveModel {
id: Set(room_id),
space_id: Set(Some(space.id)),
kind: Set(cmd.kind),
lower_name: Set(lower.clone()),
name: Set(name.to_string()),
topic: Set(cmd.topic.clone()),
position: Set(position),
parent_id: Set(cmd.parent_id),
owner_id: Set(caller),
is_private: Set(cmd.is_private),
nsfw: Set(cmd.nsfw),
user_limit: Set(cmd.user_limit),
rate_limit_per_user: Set(cmd.rate_limit_per_user),
bitrate: Set(None),
last_message_id: Set(None),
is_active: Set(true),
created_at: Set(now),
updated_at: Set(now),
};
let member_uids: Vec<i64> = if !cmd.is_private {
authority::list_members(&self.state, caller, space.id, false, false)
.await?
.into_iter()
.map(|member| member.user_id)
.collect()
} else {
vec![]
};
let member_uids_clone = member_uids.clone();
let inserted = self
.db()
.primary()
.transaction::<_, rooms::Model, SpaceError>(|txn| {
Box::pin(async move {
let r = room_row.insert(txn).await?;
for &uid in &member_uids_clone {
let rm = room_members::ActiveModel {
role: Set(if uid == caller {
room_members::RoomMemberRole::Owner
} else {
room_members::RoomMemberRole::Member
}),
room_id: Set(room_id),
user_id: Set(uid),
nick: Set(None),
joined_at: Set(now),
left_at: Set(None),
created_at: Set(now),
updated_at: Set(Some(now)),
};
rm.insert(txn).await?;
}
Ok(r)
})
})
.await?;
let actor = export::users::get_public_user(&self.state, caller)
.await
.ok()
.flatten()
.map(|user| {
serde_json::json!({
"id": user.user_id,
"username": user.username,
"display_name": user.displayname,
"avatar_url": export::resolve_user_avatar_url(user.user_id, &user.avatar_url),
})
});
let space_avatar = crate::authority::space_avatar(&self.state, &space).await?;
let request_id = export::middleware::request_id();
events::outbox::enqueue_chat_users(
self.db().write(),
"chat.membership.create",
&member_uids
.iter()
.copied()
.filter(|uid| *uid != caller)
.collect::<Vec<_>>(),
room_id,
&serde_json::json!({
"kind": "room_added",
"room_id": room_id,
"room": {
"id": inserted.id,
"space_id": inserted.space_id,
"kind": inserted.kind,
"name": &inserted.name,
"topic": &inserted.topic,
},
"space": {
"id": space.id,
"lower_name": &space.slug,
"name": &space.name,
"avatar_url": &space_avatar,
},
"actor": actor,
}),
request_id.as_deref(),
)
.await?;
Ok(RoomDto::from(inserted))
}
#[tracing::instrument(name = "space.delete_room", skip_all, fields(caller = %caller, room_id = %room_id))]
pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), SpaceError> {
let room = rooms::Entity::find_by_id(room_id)
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
if !room.is_active {
return Err(SpaceError::NotFound);
}
let Some(space_id) = room.space_id else {
return Err(SpaceError::NotFound);
};
let space = authority::space_by_id(&self.state, caller, space_id).await?;
require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
if !authority::space_is_active(&space) {
return Err(SpaceError::NotFound);
}
let actor = export::users::get_public_user(&self.state, caller)
.await
.ok()
.flatten()
.map(|user| {
serde_json::json!({
"id": user.user_id,
"username": user.username,
"display_name": user.displayname,
"avatar_url": export::resolve_user_avatar_url(user.user_id, &user.avatar_url),
})
});
let room_detail = serde_json::json!({
"id": room.id,
"space_id": room.space_id,
"kind": room.kind,
"name": &room.name,
"topic": &room.topic,
});
let now = OffsetDateTime::now_utc();
let mut am: rooms::ActiveModel = room.into();
am.is_active = Set(false);
am.updated_at = Set(now);
am.update(self.db().write()).await?;
let space_avatar = crate::authority::space_avatar(&self.state, &space).await?;
let request_id = export::middleware::request_id();
events::outbox::enqueue_room(
self.db().write(),
"chat.room.update",
room_id,
serde_json::json!({
"room_id": room_id,
"deleted": true,
"room": room_detail,
"space": {
"id": space.id,
"lower_name": &space.slug,
"name": &space.name,
"avatar_url": &space_avatar,
},
"actor": actor,
}),
request_id.as_deref(),
)
.await?;
Ok(())
}
}