gitdataai/libs/room/src/helpers.rs
ZhenYi 60d8c3a617 fix(room): resolve remaining defects from second review
- reaction.rs: query before insert to detect new vs duplicate reactions,
  only publish Redis event when a reaction was actually added
- room.rs: delete Redis seq key on room deletion to prevent seq
  collision on re-creation
- message.rs: use Redis-atomic next_room_message_seq_internal for
  concurrent safety; look up sender display name once for both
  mention notifications and response body; add warn log when
  should_ai_respond fails instead of silent unwrap_or(false)
- ws_universal.rs: re-check room access permission when re-subscribing
  dead streams after error to prevent revoked permissions being bypassed
- RoomChatPanel.tsx: truncate reply preview content to 80 chars
- RoomMessageList.tsx: remove redundant inline style on message row div
2026-04-17 20:28:45 +08:00

434 lines
15 KiB
Rust

use crate::error::RoomError;
use crate::service::RoomService;
use models::agents::model as ai_model;
use models::projects::{MemberRole, project, project_history_name, project_members};
use models::rooms::{
MessageContentType, RoomMemberRole, room, room_ai, room_category, room_member, room_message,
room_notifications, room_pin, room_thread,
};
use models::users::user as user_model;
use sea_orm::*;
use uuid::Uuid;
impl From<room_category::Model> for super::RoomCategoryResponse {
fn from(value: room_category::Model) -> Self {
Self {
id: value.id,
project: value.project,
name: value.name,
position: value.position,
created_by: value.created_by,
created_at: value.created_at,
}
}
}
impl From<room::Model> for super::RoomResponse {
fn from(value: room::Model) -> Self {
Self {
id: value.id,
project: value.project,
room_name: value.room_name,
public: value.public,
category: value.category,
created_by: value.created_by,
created_at: value.created_at,
last_msg_at: value.last_msg_at,
unread_count: 0,
}
}
}
impl From<room_member::Model> for super::RoomMemberResponse {
fn from(value: room_member::Model) -> Self {
Self {
room: value.room,
user: value.user,
user_info: None,
role: value.role.to_string(),
first_msg_in: value.first_msg_in,
joined_at: value.joined_at,
last_read_seq: value.last_read_seq,
do_not_disturb: value.do_not_disturb,
dnd_start_hour: value.dnd_start_hour,
dnd_end_hour: value.dnd_end_hour,
}
}
}
impl From<room_message::Model> for super::RoomMessageResponse {
fn from(value: room_message::Model) -> Self {
Self {
id: value.id,
seq: value.seq,
room: value.room,
sender_type: value.sender_type.to_string(),
sender_id: value.sender_id,
display_name: None,
thread: value.thread,
content: value.content,
content_type: value.content_type.to_string(),
edited_at: value.edited_at,
send_at: value.send_at,
revoked: value.revoked,
revoked_by: value.revoked_by,
in_reply_to: value.in_reply_to,
}
}
}
impl From<room_thread::Model> for super::RoomThreadResponse {
fn from(value: room_thread::Model) -> Self {
Self {
id: value.id,
room: value.room,
parent: value.parent,
created_by: value.created_by,
participants: value.participants,
last_message_at: value.last_message_at,
last_message_preview: value.last_message_preview,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
impl From<room_pin::Model> for super::RoomPinResponse {
fn from(value: room_pin::Model) -> Self {
Self {
room: value.room,
message: value.message,
pinned_by: value.pinned_by,
pinned_at: value.pinned_at,
}
}
}
impl From<room_ai::Model> for super::RoomAiResponse {
fn from(value: room_ai::Model) -> Self {
Self {
room: value.room,
model: value.model,
version: value.version,
call_count: value.call_count,
last_call_at: value.last_call_at,
history_limit: value.history_limit,
system_prompt: value.system_prompt,
temperature: value.temperature,
max_tokens: value.max_tokens,
use_exact: value.use_exact,
think: value.think,
stream: value.stream,
min_score: value.min_score,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
impl From<room_notifications::Model> for super::NotificationResponse {
fn from(value: room_notifications::Model) -> Self {
Self {
id: value.id,
room: value.room,
project: value.project,
user_id: value.user_id,
user_info: None,
notification_type: value.notification_type.to_string(),
title: value.title,
content: value.content,
related_message_id: value.related_message_id,
related_user_id: value.related_user_id,
related_room_id: value.related_room_id,
metadata: value.metadata.unwrap_or(serde_json::json!({})),
is_read: value.is_read,
is_archived: value.is_archived,
created_at: value.created_at,
read_at: value.read_at,
expires_at: value.expires_at,
}
}
}
impl RoomService {
pub(crate) fn parse_room_member_role(role: &str) -> Result<RoomMemberRole, RoomError> {
match role {
"owner" => Ok(RoomMemberRole::Owner),
"admin" => Ok(RoomMemberRole::Admin),
"member" => Ok(RoomMemberRole::Member),
"guest" => Ok(RoomMemberRole::Guest),
_ => Err(RoomError::BadRequest("invalid room role".to_string())),
}
}
pub(crate) fn parse_message_content_type(
content_type: Option<String>,
) -> Result<MessageContentType, RoomError> {
match content_type
.unwrap_or_else(|| "text".to_string())
.to_lowercase()
.as_str()
{
"text" => Ok(MessageContentType::Text),
"image" => Ok(MessageContentType::Image),
"audio" => Ok(MessageContentType::Audio),
"video" => Ok(MessageContentType::Video),
"file" => Ok(MessageContentType::File),
_ => Err(RoomError::BadRequest(
"invalid message content_type".to_string(),
)),
}
}
pub(crate) async fn find_room_member(
&self,
room_id: Uuid,
user_id: Uuid,
) -> Result<Option<room_member::Model>, RoomError> {
room_member::Entity::find_by_id((room_id, user_id))
.one(&self.db)
.await
.map_err(RoomError::from)
}
pub(crate) async fn require_room_member_model(
&self,
room_id: Uuid,
user_id: Uuid,
) -> Result<room_member::Model, RoomError> {
self.find_room_member(room_id, user_id)
.await?
.ok_or(RoomError::NoPower)
}
pub(crate) fn is_room_admin(role: &RoomMemberRole) -> bool {
matches!(role, RoomMemberRole::Owner | RoomMemberRole::Admin)
}
pub(crate) async fn require_room_admin(
&self,
room_id: Uuid,
user_id: Uuid,
) -> Result<room_member::Model, RoomError> {
let member = self.require_room_member_model(room_id, user_id).await?;
if Self::is_room_admin(&member.role) {
Ok(member)
} else {
Err(RoomError::NoPower)
}
}
pub(crate) async fn require_project_admin(
&self,
project_id: Uuid,
user_id: Uuid,
) -> Result<project_members::Model, RoomError> {
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(user_id))
.one(&self.db)
.await?
.ok_or(RoomError::NoPower)?;
let role = member.scope_role().map_err(|_| RoomError::RoleParseError)?;
if matches!(role, MemberRole::Owner | MemberRole::Admin) {
Ok(member)
} else {
Err(RoomError::NoPower)
}
}
pub(crate) async fn ensure_room_visible_for_user(
&self,
room: &room::Model,
user_id: Uuid,
) -> Result<(), RoomError> {
if self.find_room_member(room.id, user_id).await?.is_some() {
return Ok(());
}
let project_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(room.project))
.filter(project_members::Column::User.eq(user_id))
.one(&self.db)
.await?;
if room.public && project_member.is_some() {
Ok(())
} else {
Err(RoomError::NoPower)
}
}
pub async fn utils_find_project_by_name(
&self,
name: String,
) -> Result<project::Model, RoomError> {
match project::Entity::find()
.filter(project::Column::Name.eq(name.clone()))
.one(&self.db)
.await
.ok()
.flatten()
{
Some(project) => Ok(project),
None => match project_history_name::Entity::find()
.filter(project_history_name::Column::HistoryName.eq(name))
.one(&self.db)
.await
.ok()
.flatten()
{
Some(project) => self.utils_find_project_by_uid(project.project_uid).await,
None => Err(RoomError::NotFound("Project not found".to_string())),
},
}
}
pub async fn utils_find_project_by_uid(&self, uid: Uuid) -> Result<project::Model, RoomError> {
project::Entity::find_by_id(uid)
.one(&self.db)
.await
.ok()
.flatten()
.ok_or_else(|| RoomError::NotFound("Project not found".to_string()))
}
pub async fn check_project_access(
&self,
project_uid: Uuid,
user_uid: Uuid,
) -> Result<(), RoomError> {
let project = project::Entity::find_by_id(project_uid)
.one(&self.db)
.await
.ok()
.flatten()
.ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?;
if project.is_public {
return Ok(());
}
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project_uid))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?;
if member.is_some() {
Ok(())
} else {
Err(RoomError::NoPower)
}
}
pub(crate) fn validate_name(name: &str, max_len: usize) -> Result<(), RoomError> {
if name.trim().is_empty() {
return Err(RoomError::BadRequest("name cannot be empty".to_string()));
}
if name.len() > max_len {
return Err(RoomError::BadRequest(format!(
"name exceeds maximum length of {} characters",
max_len
)));
}
Ok(())
}
pub(crate) fn validate_content(content: &str, max_len: usize) -> Result<(), RoomError> {
if content.trim().is_empty() {
return Err(RoomError::BadRequest("content cannot be empty".to_string()));
}
if content.len() > max_len {
return Err(RoomError::BadRequest(format!(
"content exceeds maximum length of {} characters",
max_len
)));
}
Ok(())
}
pub(crate) fn sanitize_content(content: &str) -> String {
use std::sync::LazyLock;
static SCRIPT_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)<script[^>]*>.*?</script>").unwrap());
static STYLE_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)<style[^>]*>.*?</style>").unwrap());
static ONERROR_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonerror\s*=").unwrap());
static ONLOAD_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonload\s*=").unwrap());
static ONCLICK_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonclick\s*=").unwrap());
static ONMOUSEOVER_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bonmouseover\s*=").unwrap());
static JAVASCRIPT_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)javascript:").unwrap());
static DATA_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
LazyLock::new(|| regex_lite::Regex::new(r"(?i)data:").unwrap());
let mut result = content.to_string();
result = SCRIPT_RE.replace_all(&result, "").to_string();
result = STYLE_RE.replace_all(&result, "").to_string();
result = ONERROR_RE.replace_all(&result, "blocked=").to_string();
result = ONLOAD_RE.replace_all(&result, "blocked=").to_string();
result = ONCLICK_RE.replace_all(&result, "blocked=").to_string();
result = ONMOUSEOVER_RE.replace_all(&result, "blocked=").to_string();
result = JAVASCRIPT_RE.replace_all(&result, "blocked:").to_string();
result = DATA_RE.replace_all(&result, "blocked:").to_string();
result
}
pub async fn resolve_display_name(
&self,
msg: room_message::Model,
_room_id: Uuid,
) -> super::RoomMessageResponse {
let sender_type = msg.sender_type.to_string();
let display_name = match sender_type.as_str() {
"ai" => {
if let Some(sender_id) = msg.sender_id {
ai_model::Entity::find_by_id(sender_id)
.one(&self.db)
.await
.ok()
.flatten()
.map(|m| m.name)
} else {
None
}
}
_ => {
if let Some(sender_id) = msg.sender_id {
let user = user_model::Entity::find()
.filter(user_model::Column::Uid.eq(sender_id))
.one(&self.db)
.await
.ok()
.flatten();
user.map(|u| u.display_name.unwrap_or_else(|| u.username))
} else {
None
}
}
};
super::RoomMessageResponse {
id: msg.id,
seq: msg.seq,
room: msg.room,
sender_type,
sender_id: msg.sender_id,
display_name,
thread: msg.thread,
content: msg.content,
content_type: msg.content_type.to_string(),
edited_at: msg.edited_at,
send_at: msg.send_at,
revoked: msg.revoked,
revoked_by: msg.revoked_by,
in_reply_to: msg.in_reply_to,
}
}
}