refactor(transport): apply rustfmt formatting

This commit is contained in:
ZhenYi 2026-05-14 10:02:36 +08:00
parent 06c08148cb
commit 12eaa83b87
47 changed files with 728 additions and 296 deletions

View File

@ -1,7 +1,7 @@
use redis::AsyncCommands;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
use redis::AsyncCommands;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageAck { pub struct MessageAck {
@ -66,13 +66,17 @@ impl AckTracker {
status: AckStatus::Pending, status: AckStatus::Pending,
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
let value = serde_json::to_string(&ack) let value =
serde_json::to_string(&ack).map_err(|_| crate::error::AppTransportError::Internal)?;
let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let mut conn = self.cache.conn().await let _: () = conn
.map_err(|_| crate::error::AppTransportError::Internal)?; .set_ex(&key, &value, self.timeout.as_secs())
let _: () = conn.set_ex(&key, &value, self.timeout.as_secs())
.await .await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
@ -84,7 +88,8 @@ impl AckTracker {
message_id: Uuid, message_id: Uuid,
room_id: Uuid, room_id: Uuid,
) -> Result<(), crate::error::AppTransportError> { ) -> Result<(), crate::error::AppTransportError> {
self.update_status(message_id, room_id, AckStatus::Received).await self.update_status(message_id, room_id, AckStatus::Received)
.await
} }
pub async fn mark_persisted( pub async fn mark_persisted(
@ -101,13 +106,17 @@ impl AckTracker {
status: AckStatus::Persisted, status: AckStatus::Persisted,
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
let value = serde_json::to_string(&ack) let value =
serde_json::to_string(&ack).map_err(|_| crate::error::AppTransportError::Internal)?;
let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let mut conn = self.cache.conn().await let _: () = conn
.map_err(|_| crate::error::AppTransportError::Internal)?; .set_ex(&key, &value, self.timeout.as_secs())
let _: () = conn.set_ex(&key, &value, self.timeout.as_secs())
.await .await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
@ -119,10 +128,14 @@ impl AckTracker {
message_id: Uuid, message_id: Uuid,
room_id: Uuid, room_id: Uuid,
) -> Result<(), crate::error::AppTransportError> { ) -> Result<(), crate::error::AppTransportError> {
self.update_status(message_id, room_id, AckStatus::Delivered).await?; self.update_status(message_id, room_id, AckStatus::Delivered)
.await?;
let key = format!("ack:pending:{}:{}", room_id, message_id); let key = format!("ack:pending:{}:{}", room_id, message_id);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let _: Result<(), redis::RedisError> = conn.del(&key).await; let _: Result<(), redis::RedisError> = conn.del(&key).await;
@ -134,7 +147,8 @@ impl AckTracker {
message_id: Uuid, message_id: Uuid,
room_id: Uuid, room_id: Uuid,
) -> Result<(), crate::error::AppTransportError> { ) -> Result<(), crate::error::AppTransportError> {
self.update_status(message_id, room_id, AckStatus::Failed).await self.update_status(message_id, room_id, AckStatus::Failed)
.await
} }
pub async fn get_status( pub async fn get_status(
@ -144,10 +158,15 @@ impl AckTracker {
) -> Result<Option<MessageAck>, crate::error::AppTransportError> { ) -> Result<Option<MessageAck>, crate::error::AppTransportError> {
let key = format!("ack:pending:{}:{}", room_id, message_id); let key = format!("ack:pending:{}:{}", room_id, message_id);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let value: Option<String> = conn.get(&key).await let value: Option<String> = conn
.get(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
match value { match value {
@ -173,10 +192,14 @@ impl AckTracker {
let value = serde_json::to_string(&ack) let value = serde_json::to_string(&ack)
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let _: () = conn.set_ex(&key, &value, self.timeout.as_secs()) let _: () = conn
.set_ex(&key, &value, self.timeout.as_secs())
.await .await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
} }

View File

@ -49,10 +49,7 @@ impl NatsTransport {
} }
}); });
let client = opts let client = opts.connect(&url).await.map_err(|e| {
.connect(&url)
.await
.map_err(|e| {
warn!(error = %e, "NATS connect failed"); warn!(error = %e, "NATS connect failed");
AppTransportError::Internal AppTransportError::Internal
})?; })?;
@ -150,7 +147,10 @@ impl Transport for NatsTransport {
..Default::default() ..Default::default()
}; };
let mut messages = match stream.get_or_create_consumer(&durable, config.clone()).await { let mut messages = match stream
.get_or_create_consumer(&durable, config.clone())
.await
{
Ok(c) => match c.messages().await { Ok(c) => match c.messages().await {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
@ -200,8 +200,7 @@ impl Transport for NatsTransport {
.get_or_create_consumer(&durable, config.clone()) .get_or_create_consumer(&durable, config.clone())
.await .await
{ {
Ok(new_consumer) => { Ok(new_consumer) => match new_consumer.messages().await {
match new_consumer.messages().await {
Ok(new_messages) => { Ok(new_messages) => {
info!(subject = %subject, "NATS consumer reconnected"); info!(subject = %subject, "NATS consumer reconnected");
messages = new_messages; messages = new_messages;
@ -211,8 +210,7 @@ impl Transport for NatsTransport {
Err(e) => { Err(e) => {
warn!(subject = %subject, error = %e, "Failed to get messages from reconnected NATS consumer"); warn!(subject = %subject, error = %e, "Failed to get messages from reconnected NATS consumer");
} }
} },
}
Err(e) => { Err(e) => {
warn!(subject = %subject, error = %e, "Failed to recreate NATS consumer in reconnect loop"); warn!(subject = %subject, error = %e, "Failed to recreate NATS consumer in reconnect loop");
} }

View File

@ -23,7 +23,10 @@ impl DeduplicationManager {
) -> Result<bool, crate::error::AppTransportError> { ) -> Result<bool, crate::error::AppTransportError> {
let key = format!("dedup:{}:{}", room_id, message_id); let key = format!("dedup:{}:{}", room_id, message_id);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
// Use atomic SET NX EX to prevent race conditions. // Use atomic SET NX EX to prevent race conditions.
@ -48,10 +51,15 @@ impl DeduplicationManager {
) -> Result<bool, crate::error::AppTransportError> { ) -> Result<bool, crate::error::AppTransportError> {
let key = format!("dedup:{}:{}", room_id, message_id); let key = format!("dedup:{}:{}", room_id, message_id);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let exists: bool = conn.exists(&key).await let exists: bool = conn
.exists(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
Ok(exists) Ok(exists)

View File

@ -7,8 +7,7 @@ pub struct EncryptedMessage {
pub recipient_key_id: String, pub recipient_key_id: String,
} }
pub struct E2EEncryption { pub struct E2EEncryption {}
}
impl E2EEncryption { impl E2EEncryption {
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -8,6 +8,44 @@ pub enum AppTransportError {
TokenInvalidOrExpired, TokenInvalidOrExpired,
#[error("renewal limit exceeded")] #[error("renewal limit exceeded")]
RenewalLimitExceeded, RenewalLimitExceeded,
#[error("rate limit exceeded")]
RateLimitExceeded,
#[error("room not found")]
RoomNotFound,
#[error("access denied")]
AccessDenied,
#[error("internal error")] #[error("internal error")]
Internal, Internal,
} }
impl From<room::error::RoomError> for AppTransportError {
fn from(e: room::error::RoomError) -> Self {
match e {
room::error::RoomError::Unauthorized => AppTransportError::Unauthorized,
room::error::RoomError::NoPower => AppTransportError::AccessDenied,
room::error::RoomError::NotFound(_) => AppTransportError::RoomNotFound,
room::error::RoomError::RateLimited(_) => AppTransportError::RateLimitExceeded,
room::error::RoomError::BadRequest(_) => AppTransportError::Internal,
room::error::RoomError::Database(_) => AppTransportError::Internal,
room::error::RoomError::RoleParseError => AppTransportError::Internal,
room::error::RoomError::Internal(_) => AppTransportError::Internal,
}
}
}
impl AppTransportError {
/// Map error to a (code, error_type) pair for WS error messages.
/// Frontend can use these to distinguish auth vs rate-limit vs internal errors.
pub fn ws_error_code(&self) -> (u16, &'static str) {
match self {
AppTransportError::Unauthorized => (401, "unauthorized"),
AppTransportError::TokenInvalidOrExpired => (401, "token_invalid"),
AppTransportError::AccessDenied => (403, "access_denied"),
AppTransportError::RateLimitExceeded => (429, "rate_limit_exceeded"),
AppTransportError::RoomNotFound => (404, "not_found"),
AppTransportError::InvalidSession => (401, "invalid_session"),
AppTransportError::RenewalLimitExceeded => (429, "renewal_limit"),
AppTransportError::Internal => (500, "internal_error"),
}
}
}

View File

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use models::{RoomCategoryId, ProjectId, UserId}; use models::{ProjectId, RoomCategoryId, UserId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]

View File

@ -1,8 +1,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use models::{RoomId, Uuid};
use super::message::MessageNewService; use super::message::MessageNewService;
use models::{RoomId, Uuid};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResultService { pub struct SearchResultService {

View File

@ -2,8 +2,8 @@ use models::RoomId;
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent}; use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
use room::types::NotificationEvent; use room::types::NotificationEvent;
use crate::event::{member, message, reaction, notify};
use super::types::WsOutEvent; use super::types::WsOutEvent;
use crate::event::{member, message, notify, reaction};
pub struct EventDispatcher; pub struct EventDispatcher;
@ -120,7 +120,11 @@ impl EventDispatcher {
emoji: g.emoji.clone(), emoji: g.emoji.clone(),
count: g.count as i64, count: g.count as i64,
reacted_by_me: g.reacted_by_me, reacted_by_me: g.reacted_by_me,
users: g.users.iter().filter_map(|u| u.parse::<uuid::Uuid>().ok()).collect(), users: g
.users
.iter()
.filter_map(|u| u.parse::<uuid::Uuid>().ok())
.collect(),
}) })
.collect(), .collect(),
}, },

View File

@ -48,7 +48,11 @@ pub(crate) async fn message_list(
emoji: r.emoji.clone(), emoji: r.emoji.clone(),
count: r.count as i64, count: r.count as i64,
reacted_by_me: r.reacted_by_me, reacted_by_me: r.reacted_by_me,
users: r.users.iter().filter_map(|u| uuid::Uuid::parse_str(u).ok()).collect(), users: r
.users
.iter()
.filter_map(|u| uuid::Uuid::parse_str(u).ok())
.collect(),
}) })
.collect(), .collect(),
), ),

View File

@ -171,10 +171,12 @@ pub(crate) async fn custom_status_update(
text: Option<String>, text: Option<String>,
expires_at: Option<chrono::DateTime<chrono::Utc>>, expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Option<WsOutEvent>, AppTransportError> { ) -> Result<Option<WsOutEvent>, AppTransportError> {
let evt = session let evt = session.service.room.set_custom_status(
.service session.user.user_id,
.room emoji.clone(),
.set_custom_status(session.user.user_id, emoji.clone(), text.clone(), expires_at); text.clone(),
expires_at,
);
if let Some(data) = evt { if let Some(data) = evt {
Ok(Some(WsOutEvent::CustomStatusUpdated { Ok(Some(WsOutEvent::CustomStatusUpdated {
@ -221,27 +223,40 @@ pub(crate) async fn ban_remove() -> Result<Option<WsOutEvent>, AppTransportError
// ─── Voice (stubs) ──────────────────────────────────────────────────── // ─── Voice (stubs) ────────────────────────────────────────────────────
pub(crate) async fn voice_join(room: models::RoomId) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn voice_join(
room: models::RoomId,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%room, "Voice join"); tracing::info!(%room, "Voice join");
Ok(None) Ok(None)
} }
pub(crate) async fn voice_leave(room: models::RoomId) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn voice_leave(
room: models::RoomId,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%room, "Voice leave"); tracing::info!(%room, "Voice leave");
Ok(None) Ok(None)
} }
pub(crate) async fn voice_mute(room: models::RoomId, muted: bool) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn voice_mute(
room: models::RoomId,
muted: bool,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%room, %muted, "Voice mute"); tracing::info!(%room, %muted, "Voice mute");
Ok(None) Ok(None)
} }
pub(crate) async fn voice_deaf(room: models::RoomId, deafened: bool) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn voice_deaf(
room: models::RoomId,
deafened: bool,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%room, %deafened, "Voice deaf"); tracing::info!(%room, %deafened, "Voice deaf");
Ok(None) Ok(None)
} }
pub(crate) async fn screen_share(room: models::RoomId, start: bool) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn screen_share(
room: models::RoomId,
start: bool,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%room, %start, "Screen share"); tracing::info!(%room, %start, "Screen share");
Ok(None) Ok(None)
} }

View File

@ -2,9 +2,9 @@ use crate::error::AppTransportError;
use crate::handler::session::TransportSession; use crate::handler::session::TransportSession;
use crate::handler::types::{WsInMessage, WsOutEvent}; use crate::handler::types::{WsInMessage, WsOutEvent};
mod message;
mod misc; mod misc;
mod msg; mod msg;
mod message;
mod reaction; mod reaction;
mod room; mod room;
@ -21,14 +21,25 @@ impl MessageHandler {
WsInMessage::Unsubscribe { room } => msg::unsubscribe(session, room).await, WsInMessage::Unsubscribe { room } => msg::unsubscribe(session, room).await,
WsInMessage::TypingStart { room } => msg::typing_start(session, room).await, WsInMessage::TypingStart { room } => msg::typing_start(session, room).await,
WsInMessage::TypingStop { room } => msg::typing_stop(session, room).await, WsInMessage::TypingStop { room } => msg::typing_stop(session, room).await,
WsInMessage::ReadReceipt { room, last_read_seq } => { WsInMessage::ReadReceipt {
msg::read_receipt(session, room, last_read_seq).await room,
} last_read_seq,
WsInMessage::MessageList { room, before_seq, after_seq, limit } => { } => msg::read_receipt(session, room, last_read_seq).await,
message::message_list(session, room, before_seq, after_seq, limit).await WsInMessage::MessageList {
} room,
WsInMessage::MessageCreate { room, content, content_type, thread, in_reply_to } => { before_seq,
message::message_create(session, room, content, content_type, thread, in_reply_to).await after_seq,
limit,
} => message::message_list(session, room, before_seq, after_seq, limit).await,
WsInMessage::MessageCreate {
room,
content,
content_type,
thread,
in_reply_to,
} => {
message::message_create(session, room, content, content_type, thread, in_reply_to)
.await
} }
WsInMessage::MessageUpdate { message, content } => { WsInMessage::MessageUpdate { message, content } => {
message::message_update(session, message, content).await message::message_update(session, message, content).await
@ -37,28 +48,44 @@ impl MessageHandler {
message::message_revoke(session, message).await message::message_revoke(session, message).await
} }
WsInMessage::RoomGet { room } => room::room_get(session, room).await, WsInMessage::RoomGet { room } => room::room_get(session, room).await,
WsInMessage::RoomCreate { project, room_name, public, category } => { WsInMessage::RoomCreate {
room::room_create(session, project, room_name, public, category).await project,
} room_name,
WsInMessage::RoomUpdate { room, room_name, public, category } => { public,
room::room_update(session, room, room_name, public, category).await category,
} } => room::room_create(session, project, room_name, public, category).await,
WsInMessage::RoomUpdate {
room,
room_name,
public,
category,
} => room::room_update(session, room, room_name, public, category).await,
WsInMessage::RoomDelete { room } => room::room_delete(session, room).await, WsInMessage::RoomDelete { room } => room::room_delete(session, room).await,
WsInMessage::CategoryCreate { project, name, position } => { WsInMessage::CategoryCreate {
room::category_create(session, project, name, position).await project,
} name,
position,
} => room::category_create(session, project, name, position).await,
WsInMessage::CategoryUpdate { id, name, position } => { WsInMessage::CategoryUpdate { id, name, position } => {
room::category_update(session, id, name, position).await room::category_update(session, id, name, position).await
} }
WsInMessage::CategoryDelete { id } => room::category_delete(session, id).await, WsInMessage::CategoryDelete { id } => room::category_delete(session, id).await,
WsInMessage::AccessGrant { room, user } => room::access_grant(session, room, user).await, WsInMessage::AccessGrant { room, user } => {
WsInMessage::AccessRevoke { room, user } => room::access_revoke(session, room, user).await, room::access_grant(session, room, user).await
WsInMessage::ReactionAdd { room, message, emoji } => {
reaction::reaction_add(session, room, message, emoji).await
} }
WsInMessage::ReactionRemove { room, message, emoji } => { WsInMessage::AccessRevoke { room, user } => {
reaction::reaction_remove(session, room, message, emoji).await room::access_revoke(session, room, user).await
} }
WsInMessage::ReactionAdd {
room,
message,
emoji,
} => reaction::reaction_add(session, room, message, emoji).await,
WsInMessage::ReactionRemove {
room,
message,
emoji,
} => reaction::reaction_remove(session, room, message, emoji).await,
WsInMessage::ThreadCreate { room, parent } => { WsInMessage::ThreadCreate { room, parent } => {
reaction::thread_create(session, room, parent).await reaction::thread_create(session, room, parent).await
} }
@ -79,15 +106,35 @@ impl MessageHandler {
WsInMessage::NotificationArchive { id } => { WsInMessage::NotificationArchive { id } => {
misc::notification_archive(session, id).await misc::notification_archive(session, id).await
} }
WsInMessage::Search { q, room, start_time, end_time, sender_id, content_type, limit, offset } => { WsInMessage::Search {
misc::search(session, q, room, start_time, end_time, sender_id, content_type, limit, offset).await q,
} room,
WsInMessage::PresenceUpdate { status } => { start_time,
misc::presence_update(session, status).await end_time,
} sender_id,
WsInMessage::CustomStatusUpdate { emoji, text, expires_at } => { content_type,
misc::custom_status_update(session, emoji, text, expires_at).await limit,
offset,
} => {
misc::search(
session,
q,
room,
start_time,
end_time,
sender_id,
content_type,
limit,
offset,
)
.await
} }
WsInMessage::PresenceUpdate { status } => misc::presence_update(session, status).await,
WsInMessage::CustomStatusUpdate {
emoji,
text,
expires_at,
} => misc::custom_status_update(session, emoji, text, expires_at).await,
WsInMessage::InviteCreate { .. } => misc::invite_create().await, WsInMessage::InviteCreate { .. } => misc::invite_create().await,
WsInMessage::InviteAccept { .. } => misc::invite_accept().await, WsInMessage::InviteAccept { .. } => misc::invite_accept().await,
WsInMessage::InviteRevoke { .. } => misc::invite_revoke().await, WsInMessage::InviteRevoke { .. } => misc::invite_revoke().await,
@ -99,19 +146,44 @@ impl MessageHandler {
WsInMessage::VoiceDeaf { room, deafened } => misc::voice_deaf(room, deafened).await, WsInMessage::VoiceDeaf { room, deafened } => misc::voice_deaf(room, deafened).await,
WsInMessage::ScreenShare { room, start } => misc::screen_share(room, start).await, WsInMessage::ScreenShare { room, start } => misc::screen_share(room, start).await,
WsInMessage::AiList { room } => misc::ai_list(session, room).await, WsInMessage::AiList { room } => misc::ai_list(session, room).await,
WsInMessage::AiUpsert { room, model, version, system_prompt, temperature, max_tokens, stream } => { WsInMessage::AiUpsert {
misc::ai_upsert(session, room, model, version, system_prompt, temperature, max_tokens, stream).await room,
model,
version,
system_prompt,
temperature,
max_tokens,
stream,
} => {
misc::ai_upsert(
session,
room,
model,
version,
system_prompt,
temperature,
max_tokens,
stream,
)
.await
} }
WsInMessage::AiDelete { room, agent_id } => { WsInMessage::AiDelete { room, agent_id } => {
misc::ai_delete(session, room, agent_id).await misc::ai_delete(session, room, agent_id).await
} }
WsInMessage::AiStop { room } => misc::ai_stop(session, room).await, WsInMessage::AiStop { room } => misc::ai_stop(session, room).await,
WsInMessage::UserSummary { username } => misc::user_summary(session, username).await, WsInMessage::UserSummary { username } => misc::user_summary(session, username).await,
WsInMessage::StateSetReadSeq { room, last_read_seq } => { WsInMessage::StateSetReadSeq {
misc::state_set_read_seq(session, room, last_read_seq).await room,
} last_read_seq,
WsInMessage::StateUpdateDnd { room, do_not_disturb, dnd_start_hour, dnd_end_hour } => { } => misc::state_set_read_seq(session, room, last_read_seq).await,
misc::state_update_dnd(session, room, do_not_disturb, dnd_start_hour, dnd_end_hour).await WsInMessage::StateUpdateDnd {
room,
do_not_disturb,
dnd_start_hour,
dnd_end_hour,
} => {
misc::state_update_dnd(session, room, do_not_disturb, dnd_start_hour, dnd_end_hour)
.await
} }
} }
} }

View File

@ -3,6 +3,7 @@ use room::ws_context::WsUserContext;
use crate::error::AppTransportError; use crate::error::AppTransportError;
use crate::handler::session::TransportSession; use crate::handler::session::TransportSession;
use crate::handler::types::WsOutEvent; use crate::handler::types::WsOutEvent;
use room::connection::MAX_ROOMS_PER_SESSION;
pub(crate) async fn ping() -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn ping() -> Result<Option<WsOutEvent>, AppTransportError> {
Ok(Some(WsOutEvent::Pong { Ok(Some(WsOutEvent::Pong {
@ -14,10 +15,11 @@ pub(crate) async fn subscribe(
session: &TransportSession, session: &TransportSession,
room: models::RoomId, room: models::RoomId,
) -> Result<Option<WsOutEvent>, AppTransportError> { ) -> Result<Option<WsOutEvent>, AppTransportError> {
let sub = session.subscribe_room(room).await.map_err(|e| { // Per-session room subscription limit
tracing::warn!(error = %e, "subscribe_room failed"); if session.subscriptions.len() >= MAX_ROOMS_PER_SESSION {
AppTransportError::Internal return Err(AppTransportError::RateLimitExceeded);
})?; }
let sub = session.subscribe_room(room).await?;
session.subscriptions.insert(room, sub); session.subscriptions.insert(room, sub);
session.service.room.spawn_room_workers(room); session.service.room.spawn_room_workers(room);
session.refresh_project().await; session.refresh_project().await;

View File

@ -60,7 +60,11 @@ fn build_reaction_batch(
emoji: g.emoji, emoji: g.emoji,
count: g.count as i64, count: g.count as i64,
reacted_by_me: g.reacted_by_me, reacted_by_me: g.reacted_by_me,
users: g.users.iter().filter_map(|u| u.parse::<Uuid>().ok()).collect(), users: g
.users
.iter()
.filter_map(|u| u.parse::<Uuid>().ok())
.collect(),
}) })
.collect(), .collect(),
}, },
@ -76,7 +80,11 @@ pub(crate) async fn thread_create(
session session
.service .service
.room .room
.room_thread_create(room, room::RoomThreadCreateRequest { parent_seq: parent }, &ctx) .room_thread_create(
room,
room::RoomThreadCreateRequest { parent_seq: parent },
&ctx,
)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::warn!(error = %e, "room_thread_create failed"); tracing::warn!(error = %e, "room_thread_create failed");
@ -85,12 +93,16 @@ pub(crate) async fn thread_create(
Ok(None) Ok(None)
} }
pub(crate) async fn thread_resolve(thread_id: models::RoomThreadId) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn thread_resolve(
thread_id: models::RoomThreadId,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%thread_id, "Thread resolved"); tracing::info!(%thread_id, "Thread resolved");
Ok(None) Ok(None)
} }
pub(crate) async fn thread_archive(thread_id: models::RoomThreadId) -> Result<Option<WsOutEvent>, AppTransportError> { pub(crate) async fn thread_archive(
thread_id: models::RoomThreadId,
) -> Result<Option<WsOutEvent>, AppTransportError> {
tracing::info!(%thread_id, "Thread archived"); tracing::info!(%thread_id, "Thread archived");
Ok(None) Ok(None)
} }

View File

@ -170,11 +170,7 @@ pub(crate) async fn category_update(
session session
.service .service
.room .room
.room_category_update( .room_category_update(id, room::RoomCategoryUpdateRequest { name, position }, &ctx)
id,
room::RoomCategoryUpdateRequest { name, position },
&ctx,
)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::warn!(error = %e, "room_category_update failed"); tracing::warn!(error = %e, "room_category_update failed");

View File

@ -11,4 +11,4 @@ pub use inbound::MessageHandler;
pub use poll::poll_subscriptions; pub use poll::poll_subscriptions;
pub use session::{TransportSession, WsUserCtx}; pub use session::{TransportSession, WsUserCtx};
pub use sse::ws_ai_stream; pub use sse::ws_ai_stream;
pub use types::{WsError, WsInMessage, WsOutEvent, WS_PROTOCOL_VERSION}; pub use types::{WS_PROTOCOL_VERSION, WsError, WsInMessage, WsOutEvent};

View File

@ -84,10 +84,7 @@ pub struct TransportSession {
} }
impl TransportSession { impl TransportSession {
pub fn new( pub fn new(user: WsUserCtx, service: Arc<AppService>) -> Self {
user: WsUserCtx,
service: Arc<AppService>,
) -> Self {
Self { Self {
user, user,
subscriptions: Arc::new(DashMap::new()), subscriptions: Arc::new(DashMap::new()),
@ -116,7 +113,11 @@ impl TransportSession {
} }
pub async fn unsubscribe_room(&self, room_id: RoomId) { pub async fn unsubscribe_room(&self, room_id: RoomId) {
self.service.room.room_manager.unsubscribe(room_id, self.user.user_id).await; self.service
.room
.room_manager
.unsubscribe(room_id, self.user.user_id)
.await;
self.subscriptions.remove(&room_id); self.subscriptions.remove(&room_id);
} }
@ -129,7 +130,11 @@ impl TransportSession {
action: action.to_string(), action: action.to_string(),
sender_type: Some("user".to_string()), sender_type: Some("user".to_string()),
}; };
self.service.room.room_manager.broadcast_typing(room_id, event).await; self.service
.room
.room_manager
.broadcast_typing(room_id, event)
.await;
} }
/// Get the current project context from cache (populated on first subscription). /// Get the current project context from cache (populated on first subscription).

View File

@ -1,11 +1,11 @@
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web::web::Bytes; use actix_web::web::Bytes;
use actix_web::{HttpRequest, HttpResponse, web};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
use uuid::Uuid; use uuid::Uuid;
use service::AppService;
use queue::RoomMessageStreamChunkEvent; use queue::RoomMessageStreamChunkEvent;
use service::AppService;
/// SSE endpoint: GET /ws/ai-stream/{room_id}/{message_id} /// SSE endpoint: GET /ws/ai-stream/{room_id}/{message_id}
pub async fn ws_ai_stream( pub async fn ws_ai_stream(
@ -30,7 +30,9 @@ pub async fn ws_ai_stream(
return Err(actix_web::error::ErrorUnauthorized("invalid auth header")); return Err(actix_web::error::ErrorUnauthorized("invalid auth header"));
} }
} else if let Some(token) = req.uri().query().and_then(|q| { } else if let Some(token) = req.uri().query().and_then(|q| {
q.split('&').find(|p| p.starts_with("token=")).and_then(|p| p.split('=').nth(1)) q.split('&')
.find(|p| p.starts_with("token="))
.and_then(|p| p.split('=').nth(1))
}) { }) {
match service.ws_token.validate_token(token).await { match service.ws_token.validate_token(token).await {
Ok(uid) => uid, Ok(uid) => uid,
@ -55,8 +57,7 @@ pub async fn ws_ai_stream(
None => return Err(actix_web::error::ErrorNotFound("stream not found").into()), None => return Err(actix_web::error::ErrorNotFound("stream not found").into()),
}; };
let sse_stream = BroadcastStream::new(stream_rx) let sse_stream = BroadcastStream::new(stream_rx).map(move |result| match result {
.map(move |result| match result {
Ok(chunk) => { Ok(chunk) => {
let data = format_sse_chunk(&chunk); let data = format_sse_chunk(&chunk);
if chunk.done { if chunk.done {
@ -67,7 +68,10 @@ pub async fn ws_ai_stream(
} }
Err(BroadcastStreamRecvError::Lagged(_)) => { Err(BroadcastStreamRecvError::Lagged(_)) => {
tracing::warn!(message_id = %message_id, "SSE subscriber lagged"); tracing::warn!(message_id = %message_id, "SSE subscriber lagged");
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "stream lagged")) Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"stream lagged",
))
} }
}); });

View File

@ -11,56 +11,215 @@ pub const WS_PROTOCOL_VERSION: u32 = 1;
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum WsInMessage { pub enum WsInMessage {
Ping, Ping,
Subscribe { room: RoomId }, Subscribe {
Unsubscribe { room: RoomId }, room: RoomId,
TypingStart { room: RoomId }, },
TypingStop { room: RoomId }, Unsubscribe {
ReadReceipt { room: RoomId, last_read_seq: i64 }, room: RoomId,
MessageList { room: RoomId, before_seq: Option<i64>, after_seq: Option<i64>, limit: Option<u64> }, },
MessageCreate { room: RoomId, content: String, content_type: Option<String>, thread: Option<RoomThreadId>, in_reply_to: Option<Uuid> }, TypingStart {
MessageUpdate { message: Uuid, content: String }, room: RoomId,
MessageRevoke { message: Uuid }, },
RoomGet { room: RoomId }, TypingStop {
RoomCreate { project: ProjectId, room_name: String, public: bool, category: Option<Uuid> }, room: RoomId,
RoomUpdate { room: RoomId, room_name: Option<String>, public: Option<bool>, category: Option<Uuid> }, },
RoomDelete { room: RoomId }, ReadReceipt {
CategoryCreate { project: ProjectId, name: String, position: Option<i32> }, room: RoomId,
CategoryUpdate { id: Uuid, name: Option<String>, position: Option<i32> }, last_read_seq: i64,
CategoryDelete { id: Uuid }, },
AccessGrant { room: RoomId, user: UserId }, MessageList {
AccessRevoke { room: RoomId, user: UserId }, room: RoomId,
StateSetReadSeq { room: RoomId, last_read_seq: i64 }, before_seq: Option<i64>,
StateUpdateDnd { room: RoomId, do_not_disturb: Option<bool>, dnd_start_hour: Option<i16>, dnd_end_hour: Option<i16> }, after_seq: Option<i64>,
ReactionAdd { room: RoomId, message: Uuid, emoji: String }, limit: Option<u64>,
ReactionRemove { room: RoomId, message: Uuid, emoji: String }, },
ThreadCreate { room: RoomId, parent: i64 }, MessageCreate {
ThreadResolve { thread_id: RoomThreadId }, room: RoomId,
ThreadArchive { thread_id: RoomThreadId }, content: String,
PinAdd { room: RoomId, message: Uuid }, content_type: Option<String>,
PinRemove { room: RoomId, message: Uuid }, thread: Option<RoomThreadId>,
DraftSave { room: RoomId, content: String }, in_reply_to: Option<Uuid>,
DraftClear { room: RoomId }, },
Search { q: String, room: Option<RoomId>, start_time: Option<DateTime<Utc>>, end_time: Option<DateTime<Utc>>, sender_id: Option<Uuid>, content_type: Option<String>, limit: Option<u64>, offset: Option<u64> }, MessageUpdate {
NotificationMarkRead { id: Uuid }, message: Uuid,
NotificationMarkAllRead { project_id: Option<ProjectId> }, content: String,
NotificationArchive { id: Uuid }, },
PresenceUpdate { status: crate::event::presence::UserPresenceStatus }, MessageRevoke {
CustomStatusUpdate { emoji: Option<String>, text: Option<String>, expires_at: Option<DateTime<Utc>> }, message: Uuid,
InviteCreate { project: ProjectId, room: Option<RoomId>, max_uses: Option<i32>, expires_at: Option<DateTime<Utc>> }, },
InviteAccept { code: String }, RoomGet {
InviteRevoke { id: Uuid }, room: RoomId,
BanCreate { project: ProjectId, user: UserId, reason: Option<String>, expires_at: Option<DateTime<Utc>> }, },
BanRemove { project: ProjectId, user: UserId }, RoomCreate {
VoiceJoin { room: RoomId }, project: ProjectId,
VoiceLeave { room: RoomId }, room_name: String,
VoiceMute { room: RoomId, muted: bool }, public: bool,
VoiceDeaf { room: RoomId, deafened: bool }, category: Option<Uuid>,
ScreenShare { room: RoomId, start: bool }, },
AiList { room: RoomId }, RoomUpdate {
AiUpsert { room: RoomId, model: Uuid, version: Option<Uuid>, system_prompt: Option<String>, temperature: Option<f64>, max_tokens: Option<i64>, stream: Option<bool> }, room: RoomId,
AiDelete { room: RoomId, agent_id: Uuid }, room_name: Option<String>,
AiStop { room: RoomId }, public: Option<bool>,
UserSummary { username: String }, category: Option<Uuid>,
},
RoomDelete {
room: RoomId,
},
CategoryCreate {
project: ProjectId,
name: String,
position: Option<i32>,
},
CategoryUpdate {
id: Uuid,
name: Option<String>,
position: Option<i32>,
},
CategoryDelete {
id: Uuid,
},
AccessGrant {
room: RoomId,
user: UserId,
},
AccessRevoke {
room: RoomId,
user: UserId,
},
StateSetReadSeq {
room: RoomId,
last_read_seq: i64,
},
StateUpdateDnd {
room: RoomId,
do_not_disturb: Option<bool>,
dnd_start_hour: Option<i16>,
dnd_end_hour: Option<i16>,
},
ReactionAdd {
room: RoomId,
message: Uuid,
emoji: String,
},
ReactionRemove {
room: RoomId,
message: Uuid,
emoji: String,
},
ThreadCreate {
room: RoomId,
parent: i64,
},
ThreadResolve {
thread_id: RoomThreadId,
},
ThreadArchive {
thread_id: RoomThreadId,
},
PinAdd {
room: RoomId,
message: Uuid,
},
PinRemove {
room: RoomId,
message: Uuid,
},
DraftSave {
room: RoomId,
content: String,
},
DraftClear {
room: RoomId,
},
Search {
q: String,
room: Option<RoomId>,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,
sender_id: Option<Uuid>,
content_type: Option<String>,
limit: Option<u64>,
offset: Option<u64>,
},
NotificationMarkRead {
id: Uuid,
},
NotificationMarkAllRead {
project_id: Option<ProjectId>,
},
NotificationArchive {
id: Uuid,
},
PresenceUpdate {
status: crate::event::presence::UserPresenceStatus,
},
CustomStatusUpdate {
emoji: Option<String>,
text: Option<String>,
expires_at: Option<DateTime<Utc>>,
},
InviteCreate {
project: ProjectId,
room: Option<RoomId>,
max_uses: Option<i32>,
expires_at: Option<DateTime<Utc>>,
},
InviteAccept {
code: String,
},
InviteRevoke {
id: Uuid,
},
BanCreate {
project: ProjectId,
user: UserId,
reason: Option<String>,
expires_at: Option<DateTime<Utc>>,
},
BanRemove {
project: ProjectId,
user: UserId,
},
VoiceJoin {
room: RoomId,
},
VoiceLeave {
room: RoomId,
},
VoiceMute {
room: RoomId,
muted: bool,
},
VoiceDeaf {
room: RoomId,
deafened: bool,
},
ScreenShare {
room: RoomId,
start: bool,
},
AiList {
room: RoomId,
},
AiUpsert {
room: RoomId,
model: Uuid,
version: Option<Uuid>,
system_prompt: Option<String>,
temperature: Option<f64>,
max_tokens: Option<i64>,
stream: Option<bool>,
},
AiDelete {
room: RoomId,
agent_id: Uuid,
},
AiStop {
room: RoomId,
},
UserSummary {
username: String,
},
} }
impl WsInMessage { impl WsInMessage {

View File

@ -1,5 +1,5 @@
mod in_message; mod in_message;
mod out_event; mod out_event;
pub use in_message::{WsInMessage, WS_PROTOCOL_VERSION}; pub use in_message::{WS_PROTOCOL_VERSION, WsInMessage};
pub use out_event::{WsError, WsOutEvent}; pub use out_event::{WsError, WsOutEvent};

View File

@ -1,11 +1,11 @@
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
use models::{ProjectId, RoomId, UserId};
use crate::event::{ use crate::event::{
ai, attachment, ban, category, draft, invite, member, message, notify, pin, presence, project, ai, attachment, ban, category, draft, invite, member, message, notify, pin, presence, project,
reaction, rooms, search, thread, voice, reaction, rooms, search, thread, voice,
}; };
use models::{ProjectId, RoomId, UserId};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]

View File

@ -2,7 +2,7 @@ use std::panic::AssertUnwindSafe;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use actix_web::{web, HttpRequest, HttpResponse}; use actix_web::{HttpRequest, HttpResponse, web};
use actix_ws::Message as WsMessage; use actix_ws::Message as WsMessage;
use futures_util::FutureExt; use futures_util::FutureExt;
use uuid::Uuid; use uuid::Uuid;
@ -11,7 +11,10 @@ use service::AppService;
use super::inbound::MessageHandler; use super::inbound::MessageHandler;
use super::poll::{poll_notifications, poll_subscriptions}; use super::poll::{poll_notifications, poll_subscriptions};
use super::session::{TransportSession, WsUserCtx, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, MAX_IDLE_TIMEOUT, MAX_TEXT_MESSAGE_LEN, MAX_MESSAGES_PER_SECOND}; use super::session::{
HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, MAX_IDLE_TIMEOUT, MAX_MESSAGES_PER_SECOND,
MAX_TEXT_MESSAGE_LEN, TransportSession, WsUserCtx,
};
use super::types::{WsInMessage, WsOutEvent}; use super::types::{WsInMessage, WsOutEvent};
/// Universal WebSocket endpoint: `/ws` /// Universal WebSocket endpoint: `/ws`
@ -183,10 +186,11 @@ pub async fn ws_handler(
Err(e) => { Err(e) => {
tracing::warn!(user_id = %user_id, error = %e, "WS message processing failed"); tracing::warn!(user_id = %user_id, error = %e, "WS message processing failed");
let rid = request_id.unwrap_or(Uuid::nil()); let rid = request_id.unwrap_or(Uuid::nil());
let (code, error_type) = e.ws_error_code();
let err_json = serde_json::json!({ let err_json = serde_json::json!({
"type": "error", "type": "error",
"code": 500, "code": code,
"error": "internal_error", "error": error_type,
"message": e.to_string(), "message": e.to_string(),
"_request_id": rid "_request_id": rid
}); });
@ -202,14 +206,21 @@ pub async fn ws_handler(
} }
} }
} }
Some(Ok(WsMessage::Binary(_))) => { break; } Some(Ok(WsMessage::Binary(_))) => {
let _ = ws_session.close(Some(actix_ws::CloseCode::Unsupported.into())).await;
break;
}
Some(Ok(WsMessage::Continuation(_))) => {} Some(Ok(WsMessage::Continuation(_))) => {}
Some(Ok(WsMessage::Nop)) => {} Some(Ok(WsMessage::Nop)) => {}
Some(Ok(WsMessage::Close(reason))) => { Some(Ok(WsMessage::Close(reason))) => {
let _ = ws_session.close(reason).await; let _ = ws_session.close(reason).await;
break; break;
} }
Some(Err(e)) => { tracing::warn!(error = %e, "WS transport error"); break; } Some(Err(e)) => {
tracing::warn!(error = %e, "WS transport error");
let _ = ws_session.close(Some(actix_ws::CloseCode::Protocol.into())).await;
break;
}
None => break, None => break,
} }
} }
@ -221,6 +232,9 @@ pub async fn ws_handler(
manager.unsubscribe(sub.room_id, user_id).await; manager.unsubscribe(sub.room_id, user_id).await;
} }
manager.unsubscribe_user_notification(user_id).await; manager.unsubscribe_user_notification(user_id).await;
// Remove presence entry so disconnected users don't appear online for up to 10 minutes
let project_id = session.project_id.lock().await;
session.service.room.remove_user_presence(user_id, *project_id).await;
manager.metrics.ws_connections_active.decrement(1.0); manager.metrics.ws_connections_active.decrement(1.0);
manager.metrics.ws_disconnections_total.increment(1); manager.metrics.ws_disconnections_total.increment(1);
}).catch_unwind(); }).catch_unwind();
@ -264,13 +278,20 @@ async fn authenticate_ws(
if let Ok(auth_str) = auth_header.to_str() { if let Ok(auth_str) = auth_header.to_str() {
if let Some(token) = auth_str.strip_prefix("Bearer ") { if let Some(token) = auth_str.strip_prefix("Bearer ") {
match service.ws_token.validate_token_ctx(token).await { match service.ws_token.validate_token_ctx(token).await {
Ok(ctx) => return Ok(crate::token::AppTransportTokenContext { Ok(ctx) => {
return Ok(crate::token::AppTransportTokenContext {
user_id: ctx.user_id, user_id: ctx.user_id,
device_id: ctx.device_id.unwrap_or_default(), device_id: ctx.device_id.unwrap_or_default(),
client_id: ctx.client_id.unwrap_or_default(), client_id: ctx.client_id.unwrap_or_default(),
}), });
}
Err(_) => { Err(_) => {
service.room.room_manager.metrics.ws_auth_failures.increment(1); service
.room
.room_manager
.metrics
.ws_auth_failures
.increment(1);
return Err(actix_web::error::ErrorUnauthorized("token auth failed")); return Err(actix_web::error::ErrorUnauthorized("token auth failed"));
} }
} }
@ -280,21 +301,35 @@ async fn authenticate_ws(
// Fallback: token in query string (deprecated, kept for backward compatibility) // Fallback: token in query string (deprecated, kept for backward compatibility)
if let Some(token) = req.uri().query().and_then(|q| { if let Some(token) = req.uri().query().and_then(|q| {
q.split('&').find(|p| p.starts_with("token=")).and_then(|p| p.split('=').nth(1)) q.split('&')
.find(|p| p.starts_with("token="))
.and_then(|p| p.split('=').nth(1))
}) { }) {
match service.ws_token.validate_token_ctx(token).await { match service.ws_token.validate_token_ctx(token).await {
Ok(ctx) => return Ok(crate::token::AppTransportTokenContext { Ok(ctx) => {
return Ok(crate::token::AppTransportTokenContext {
user_id: ctx.user_id, user_id: ctx.user_id,
device_id: ctx.device_id.unwrap_or_default(), device_id: ctx.device_id.unwrap_or_default(),
client_id: ctx.client_id.unwrap_or_default(), client_id: ctx.client_id.unwrap_or_default(),
}), });
}
Err(_) => { Err(_) => {
service.room.room_manager.metrics.ws_auth_failures.increment(1); service
.room
.room_manager
.metrics
.ws_auth_failures
.increment(1);
return Err(actix_web::error::ErrorUnauthorized("token auth failed")); return Err(actix_web::error::ErrorUnauthorized("token auth failed"));
} }
} }
} }
service.room.room_manager.metrics.ws_auth_failures.increment(1); service
.room
.room_manager
.metrics
.ws_auth_failures
.increment(1);
Err(actix_web::error::ErrorUnauthorized("no auth provided")) Err(actix_web::error::ErrorUnauthorized("no auth provided"))
} }

View File

@ -1,9 +1,9 @@
use std::collections::HashMap; use crate::seq::SeqAllocator;
use config::AppConfig; use config::AppConfig;
use db::cache::AppCache; use db::cache::AppCache;
use db::database::AppDatabase; use db::database::AppDatabase;
use service::AppService; use service::AppService;
use crate::seq::SeqAllocator; use std::collections::HashMap;
#[derive(Clone)] #[derive(Clone)]
pub struct AppTransport { pub struct AppTransport {
@ -103,7 +103,11 @@ impl AppTransport {
Self::build(service, config, nats) Self::build(service, config, nats)
} }
fn build(service: AppService, config: AppConfig, nats: Option<async_nats::Client>) -> Result<Self, crate::error::AppTransportError> { fn build(
service: AppService,
config: AppConfig,
nats: Option<async_nats::Client>,
) -> Result<Self, crate::error::AppTransportError> {
Ok(Self { Ok(Self {
db: service.db.clone(), db: service.db.clone(),
cache: service.cache.clone(), cache: service.cache.clone(),
@ -151,7 +155,10 @@ impl AppTransport {
self.seq.seq(room).await self.seq.seq(room).await
} }
pub async fn bootstrap_seq(&self, room: models::RoomId) -> Result<i64, crate::error::AppTransportError> { pub async fn bootstrap_seq(
&self,
room: models::RoomId,
) -> Result<i64, crate::error::AppTransportError> {
self.seq.bootstrap(room).await self.seq.bootstrap(room).await
} }

View File

@ -19,22 +19,27 @@ impl TransportMetrics {
} }
pub fn increment_sent(&self) { pub fn increment_sent(&self) {
self.messages_sent.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.messages_sent
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
} }
pub fn increment_received(&self) { pub fn increment_received(&self) {
self.messages_received.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.messages_received
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
} }
pub fn increment_failed(&self) { pub fn increment_failed(&self) {
self.messages_failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.messages_failed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
} }
pub fn increment_connections(&self) { pub fn increment_connections(&self) {
self.active_connections.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.active_connections
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
} }
pub fn decrement_connections(&self) { pub fn decrement_connections(&self) {
self.active_connections.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); self.active_connections
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
} }
} }

View File

@ -49,8 +49,8 @@ impl MessagePagination {
&self, &self,
params: PaginationParams, params: PaginationParams,
) -> Result<MessagePage, crate::error::AppTransportError> { ) -> Result<MessagePage, crate::error::AppTransportError> {
use sea_orm::*;
use models::rooms::room_message; use models::rooms::room_message;
use sea_orm::*;
let limit = std::cmp::Ord::min(params.limit, 100); let limit = std::cmp::Ord::min(params.limit, 100);
let cursor_seq = if let Some(cursor) = params.cursor { let cursor_seq = if let Some(cursor) = params.cursor {
@ -59,18 +59,16 @@ impl MessagePagination {
None None
}; };
let mut query = room_message::Entity::find() let mut query =
.filter(room_message::Column::Room.eq(params.room_id)); room_message::Entity::find().filter(room_message::Column::Room.eq(params.room_id));
query = match (params.direction, cursor_seq) { query = match (params.direction, cursor_seq) {
(PaginationDirection::Before, Some(seq)) => { (PaginationDirection::Before, Some(seq)) => query
query.filter(room_message::Column::Seq.lt(seq)) .filter(room_message::Column::Seq.lt(seq))
.order_by_desc(room_message::Column::Seq) .order_by_desc(room_message::Column::Seq),
} (PaginationDirection::After, Some(seq)) => query
(PaginationDirection::After, Some(seq)) => { .filter(room_message::Column::Seq.gt(seq))
query.filter(room_message::Column::Seq.gt(seq)) .order_by_asc(room_message::Column::Seq),
.order_by_asc(room_message::Column::Seq)
}
_ => query.order_by_desc(room_message::Column::Seq), _ => query.order_by_desc(room_message::Column::Seq),
}; };
@ -119,8 +117,8 @@ impl MessagePagination {
message_id: Uuid, message_id: Uuid,
context_size: u64, context_size: u64,
) -> Result<MessagePage, crate::error::AppTransportError> { ) -> Result<MessagePage, crate::error::AppTransportError> {
use sea_orm::*;
use models::rooms::room_message; use models::rooms::room_message;
use sea_orm::*;
let target = room_message::Entity::find_by_id(message_id) let target = room_message::Entity::find_by_id(message_id)
.one(&self.db) .one(&self.db)

View File

@ -1,7 +1,7 @@
use redis::AsyncCommands;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use redis::AsyncCommands;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientState { pub struct ClientState {
@ -40,10 +40,14 @@ impl ReconnectManager {
let key = format!("client:state:{}:{}", user_id, room_id); let key = format!("client:state:{}:{}", user_id, room_id);
let value = last_seq.to_string(); let value = last_seq.to_string();
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let _: () = conn.set_ex(&key, &value, 86400) let _: () = conn
.set_ex(&key, &value, 86400)
.await .await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
@ -57,10 +61,15 @@ impl ReconnectManager {
) -> Result<Option<i64>, crate::error::AppTransportError> { ) -> Result<Option<i64>, crate::error::AppTransportError> {
let key = format!("client:state:{}:{}", user_id, room_id); let key = format!("client:state:{}:{}", user_id, room_id);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let value: Option<String> = conn.get(&key).await let value: Option<String> = conn
.get(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
Ok(value.and_then(|v| v.parse::<i64>().ok())) Ok(value.and_then(|v| v.parse::<i64>().ok()))
@ -72,8 +81,8 @@ impl ReconnectManager {
room_id: Uuid, room_id: Uuid,
since_seq: i64, since_seq: i64,
) -> Result<Vec<MissedMessage>, crate::error::AppTransportError> { ) -> Result<Vec<MissedMessage>, crate::error::AppTransportError> {
use sea_orm::*;
use models::rooms::room_message; use models::rooms::room_message;
use sea_orm::*;
let messages = room_message::Entity::find() let messages = room_message::Entity::find()
.filter(room_message::Column::Room.eq(room_id)) .filter(room_message::Column::Room.eq(room_id))
@ -107,7 +116,9 @@ impl ReconnectManager {
let mut result = HashMap::new(); let mut result = HashMap::new();
for (room_id, client_seq) in room_states { for (room_id, client_seq) in room_states {
let missed = self.get_missed_messages(user_id, room_id, client_seq).await?; let missed = self
.get_missed_messages(user_id, room_id, client_seq)
.await?;
if !missed.is_empty() { if !missed.is_empty() {
result.insert(room_id, missed); result.insert(room_id, missed);
} }

View File

@ -20,8 +20,7 @@ pub enum BlockType {
Image, Image,
} }
pub struct RichTextRenderer { pub struct RichTextRenderer {}
}
impl RichTextRenderer { impl RichTextRenderer {
pub fn new() -> Self { pub fn new() -> Self {
@ -59,14 +58,29 @@ impl RichTextRenderer {
} }
pub fn render_to_html(&self, blocks: &[RichTextBlock]) -> String { pub fn render_to_html(&self, blocks: &[RichTextBlock]) -> String {
blocks.iter() blocks
.iter()
.map(|block| match block.block_type { .map(|block| match block.block_type {
BlockType::Text => format!("<p>{}</p>", html_escape(&block.content)), BlockType::Text => format!("<p>{}</p>", html_escape(&block.content)),
BlockType::Code => format!("<pre><code>{}</code></pre>", html_escape(&block.content)), BlockType::Code => {
BlockType::Quote => format!("<blockquote>{}</blockquote>", html_escape(&block.content)), format!("<pre><code>{}</code></pre>", html_escape(&block.content))
BlockType::Link => format!("<a href=\"{}\">{}</a>", html_escape(&block.content), html_escape(&block.content)), }
BlockType::Mention => format!("<span class=\"mention\">@{}</span>", html_escape(&block.content)), BlockType::Quote => {
BlockType::Emoji => format!("<span class=\"emoji\">{}</span>", html_escape(&block.content)), format!("<blockquote>{}</blockquote>", html_escape(&block.content))
}
BlockType::Link => format!(
"<a href=\"{}\">{}</a>",
html_escape(&block.content),
html_escape(&block.content)
),
BlockType::Mention => format!(
"<span class=\"mention\">@{}</span>",
html_escape(&block.content)
),
BlockType::Emoji => format!(
"<span class=\"emoji\">{}</span>",
html_escape(&block.content)
),
BlockType::Image => format!("<img src=\"{}\" />", html_escape(&block.content)), BlockType::Image => format!("<img src=\"{}\" />", html_escape(&block.content)),
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()

View File

@ -40,8 +40,8 @@ impl SearchEngine {
&self, &self,
query: SearchQuery, query: SearchQuery,
) -> Result<SearchResult, crate::error::AppTransportError> { ) -> Result<SearchResult, crate::error::AppTransportError> {
use sea_orm::*;
use models::rooms::room_message; use models::rooms::room_message;
use sea_orm::*;
let mut db_query = room_message::Entity::find(); let mut db_query = room_message::Entity::find();
@ -53,14 +53,18 @@ impl SearchEngine {
db_query = db_query.filter(room_message::Column::SenderId.eq(user_id)); db_query = db_query.filter(room_message::Column::SenderId.eq(user_id));
} }
let escaped_query = query.query let escaped_query = query
.query
.replace('\\', "\\\\") .replace('\\', "\\\\")
.replace('%', "\\%") .replace('%', "\\%")
.replace('_', "\\_"); .replace('_', "\\_");
let search_term = format!("%{}%", escaped_query); let search_term = format!("%{}%", escaped_query);
db_query = db_query.filter(room_message::Column::Content.like(&search_term)); db_query = db_query.filter(room_message::Column::Content.like(&search_term));
let total = db_query.clone().count(&self.db).await let total = db_query
.clone()
.count(&self.db)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let messages = db_query let messages = db_query

View File

@ -25,7 +25,10 @@ impl RateLimiter {
) -> Result<bool, crate::error::AppTransportError> { ) -> Result<bool, crate::error::AppTransportError> {
let key = format!("ratelimit:{}:{}", user_id, action); let key = format!("ratelimit:{}:{}", user_id, action);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
// Atomic INCR with EX NX — sets TTL only on first creation // Atomic INCR with EX NX — sets TTL only on first creation
@ -57,10 +60,15 @@ impl RateLimiter {
) -> Result<u32, crate::error::AppTransportError> { ) -> Result<u32, crate::error::AppTransportError> {
let key = format!("ratelimit:{}:{}", user_id, action); let key = format!("ratelimit:{}:{}", user_id, action);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let count: Option<u32> = conn.get(&key).await let count: Option<u32> = conn
.get(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let current = count.unwrap_or(0); let current = count.unwrap_or(0);
@ -85,10 +93,14 @@ impl CsrfProtection {
let token = Uuid::new_v4().to_string(); let token = Uuid::new_v4().to_string();
let key = format!("csrf:{}:{}", user_id, token); let key = format!("csrf:{}:{}", user_id, token);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let _: () = conn.set_ex(&key, "1", 3600) let _: () = conn
.set_ex(&key, "1", 3600)
.await .await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
@ -102,14 +114,21 @@ impl CsrfProtection {
) -> Result<bool, crate::error::AppTransportError> { ) -> Result<bool, crate::error::AppTransportError> {
let key = format!("csrf:{}:{}", user_id, token); let key = format!("csrf:{}:{}", user_id, token);
let mut conn = self.cache.conn().await let mut conn = self
.cache
.conn()
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
let exists: bool = conn.exists(&key).await let exists: bool = conn
.exists(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
if exists { if exists {
let _: () = conn.del(&key).await let _: () = conn
.del(&key)
.await
.map_err(|_| crate::error::AppTransportError::Internal)?; .map_err(|_| crate::error::AppTransportError::Internal)?;
} }

View File

@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use dashmap::DashMap; use dashmap::DashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;

View File

@ -1,11 +1,11 @@
use base64::Engine; use base64::Engine;
use hmac::{Hmac, Mac, KeyInit}; use hmac::{Hmac, KeyInit, Mac};
use models::UserId; use models::UserId;
use session::Session; use session::Session;
use sha2::Sha256; use sha2::Sha256;
use crate::error::AppTransportError;
use crate::AppTransport; use crate::AppTransport;
use crate::error::AppTransportError;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;

View File

@ -69,7 +69,7 @@ impl UnreadManager {
user_id: Uuid, user_id: Uuid,
room_id: Uuid, room_id: Uuid,
) -> Result<i64, crate::error::AppTransportError> { ) -> Result<i64, crate::error::AppTransportError> {
use models::rooms::{room_user_state, room_message}; use models::rooms::{room_message, room_user_state};
use sea_orm::*; use sea_orm::*;
let state = room_user_state::Entity::find_by_id((room_id, user_id)) let state = room_user_state::Entity::find_by_id((room_id, user_id))
@ -93,7 +93,7 @@ impl UnreadManager {
&self, &self,
user_id: Uuid, user_id: Uuid,
) -> Result<Vec<UnreadCount>, crate::error::AppTransportError> { ) -> Result<Vec<UnreadCount>, crate::error::AppTransportError> {
use models::rooms::{room_user_state, room_message}; use models::rooms::{room_message, room_user_state};
use sea_orm::*; use sea_orm::*;
let states = room_user_state::Entity::find() let states = room_user_state::Entity::find()