- 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
434 lines
15 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|