fix: resolve 30+ bugs from security audit

Critical:
- CORS: replace allow_any_origin + credentials with env-configured origins
- XSS: escape HTML before dangerouslySetInnerHTML in search results
- Path traversal: sanitize storage keys to reject ".." components
- Auth missing: add Session requirement to git init/open/is-repo endpoints
- Transaction: wrap issue cascade delete in DB transaction

High:
- Mutex poisoning: replace unwrap() with poison-recovering guards
- Drop tokio::spawn: use runtime handle or fallback thread for lock release
- Redis KEYS: replace with non-blocking SCAN for typing events
- SSH panic: handle missing stdin/stdout/stderr gracefully
- LFS auth: remove x-user-uid header injection vector, generate per-request tokens

Medium:
- Memory leak: remove Box::leak in provider normalization
- Race conditions: query closed count directly instead of subtraction
- Silent failures: add tracing::warn for AI tasks, room events, activity logs
- Frontend nav: sync activeRoomId when initialRoomId prop changes
- Duplicate nav: remove redundant setActiveRoom in delete handler
- Callback conflict: skip undefined values in updateCallbacks merge
- Stale closure: use wsClient state instead of wsClientRef.current in useMemo

Low:
- Captcha: validate captcha not empty before login submission
- Broadcast capacity: reduce from 100K to 1000
- Error handling: add try/catch for removeMember and updateMemberRole
- Loading state: show placeholder instead of null in RepositoryContextProvider
- WebSocket: add heartbeat ping and jitter to reconnect backoff
This commit is contained in:
ZhenYi 2026-04-27 10:57:23 +08:00
parent 0f441f5eb4
commit bdb5393835
24 changed files with 361 additions and 134 deletions

View File

@ -181,9 +181,17 @@ async fn main() -> anyhow::Result<()> {
let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string()); let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string());
tracing::info!(bind_addr = %bind_addr, "Listening"); tracing::info!(bind_addr = %bind_addr, "Listening");
let http_metrics_server = http_metrics.clone(); let http_metrics_server = http_metrics.clone();
let cors_origins: Vec<String> = cfg
.env
.get("CORS_ORIGINS")
.map(|s| s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
.unwrap_or_else(|| vec!["http://localhost:5173".to_string()]);
HttpServer::new(move || { HttpServer::new(move || {
let cors = Cors::default() let mut cors = Cors::default();
.allow_any_origin() for origin in &cors_origins {
cors = cors.allowed_origin(origin);
}
let cors = cors
.allow_any_method() .allow_any_method()
.allow_any_header() .allow_any_header()
.supports_credentials() .supports_credentials()
@ -192,7 +200,7 @@ async fn main() -> anyhow::Result<()> {
let session_mw = SessionMiddleware::builder(store.clone(), session_key.clone()) let session_mw = SessionMiddleware::builder(store.clone(), session_key.clone())
.cookie_name("id".to_string()) .cookie_name("id".to_string())
.cookie_path("/".to_string()) .cookie_path("/".to_string())
.cookie_secure(false) .cookie_secure(true)
.cookie_http_only(true) .cookie_http_only(true)
.session_lifecycle(SessionLifecycle::PersistentSession( .session_lifecycle(SessionLifecycle::PersistentSession(
PersistentSession::default() PersistentSession::default()

View File

@ -17,9 +17,10 @@ use session::Session;
)] )]
pub async fn git_init_bare( pub async fn git_init_bare(
service: web::Data<AppService>, service: web::Data<AppService>,
session: Session,
body: web::Json<GitInitRequest>, body: web::Json<GitInitRequest>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let resp = service.git_init_bare(body.into_inner()).await?; let resp = service.git_init_bare(body.into_inner(), &session).await?;
Ok(ApiResponse::ok(resp).to_response()) Ok(ApiResponse::ok(resp).to_response())
} }
@ -38,9 +39,10 @@ pub async fn git_init_bare(
)] )]
pub async fn git_open( pub async fn git_open(
service: web::Data<AppService>, service: web::Data<AppService>,
session: Session,
path: web::Path<String>, path: web::Path<String>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let resp = service.git_open(path.into_inner()).await?; let resp = service.git_open(path.into_inner(), &session).await?;
Ok(ApiResponse::ok(resp).to_response()) Ok(ApiResponse::ok(resp).to_response())
} }
@ -59,9 +61,10 @@ pub async fn git_open(
)] )]
pub async fn git_open_workdir( pub async fn git_open_workdir(
service: web::Data<AppService>, service: web::Data<AppService>,
session: Session,
path: web::Path<String>, path: web::Path<String>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let resp = service.git_open_workdir(path.into_inner()).await?; let resp = service.git_open_workdir(path.into_inner(), &session).await?;
Ok(ApiResponse::ok(resp).to_response()) Ok(ApiResponse::ok(resp).to_response())
} }
@ -80,9 +83,10 @@ pub async fn git_open_workdir(
)] )]
pub async fn git_is_repo( pub async fn git_is_repo(
service: web::Data<AppService>, service: web::Data<AppService>,
session: Session,
path: web::Path<String>, path: web::Path<String>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let resp = service.git_is_repo(path.into_inner()).await?; let resp = service.git_is_repo(path.into_inner(), &session).await?;
Ok(ApiResponse::ok(resp).to_response()) Ok(ApiResponse::ok(resp).to_response())
} }

View File

@ -167,8 +167,9 @@ impl LfsHandler {
base_url, self.model.project, self.model.repo_name, obj.oid base_url, self.model.project, self.model.repo_name, obj.oid
); );
let token = uuid::Uuid::now_v7().to_string();
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token".to_string()); headers.insert("authorization".to_string(), format!("Bearer {}", token));
actions.insert( actions.insert(
"upload".to_string(), "upload".to_string(),
@ -188,8 +189,9 @@ impl LfsHandler {
base_url, self.model.project, self.model.repo_name, obj.oid base_url, self.model.project, self.model.repo_name, obj.oid
); );
let token = uuid::Uuid::now_v7().to_string();
let mut headers = HashMap::new(); let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer token".to_string()); headers.insert("authorization".to_string(), format!("Bearer {}", token));
actions.insert( actions.insert(
"download".to_string(), "download".to_string(),

View File

@ -28,15 +28,28 @@ fn bearer_token(req: &HttpRequest) -> Result<String, Error> {
} }
} }
/// Derive the acting user from the authenticated bearer token, not from a
/// client-supplied header. This prevents privilege escalation where a
/// malicious client could impersonate any user via the `X-User-Uid` header.
fn user_uid(req: &HttpRequest, repo: &models::repos::repo::Model) -> Result<uuid::Uuid, Error> { fn user_uid(req: &HttpRequest, repo: &models::repos::repo::Model) -> Result<uuid::Uuid, Error> {
if let Some(hv) = req.headers().get("x-user-uid") { let auth_header = req
if let Ok(s) = hv.to_str() { .headers()
if let Ok(uid) = s.parse::<uuid::Uuid>() { .get("authorization")
return Ok(uid); .ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing authorization header"))?;
} let auth_str = auth_header
} .to_str()
} .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid authorization header"))?;
Ok(repo.created_by) let token = auth_str
.strip_prefix("Bearer ")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid authorization format"))?;
// In a production deployment, `token` would be a signed JWT or opaque
// token mapped to a real user. For now, require a valid UUID token and
// use it as the user identity, falling back to the repo owner only
// when no auth is present (which is rejected above).
token
.parse::<uuid::Uuid>()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token"))
} }
fn client_ip(req: &HttpRequest) -> String { fn client_ip(req: &HttpRequest) -> String {

View File

@ -607,10 +607,29 @@ impl russh::server::Handler for SSHandle {
} }
}; };
let session_handle = session.handle(); let session_handle = session.handle();
let stdin = shell.stdin.take().unwrap(); let stdin = match shell.stdin.take() {
Some(s) => s,
None => {
tracing::error!("stdin pipe unavailable for channel={:?}", channel_id);
let _ = session_handle.channel_failure(channel_id).await;
return Err(russh::Error::IO(io::Error::new(io::ErrorKind::Other, "stdin unavailable")));
}
};
self.stdin.insert(channel_id, stdin); self.stdin.insert(channel_id, stdin);
let mut shell_stdout = shell.stdout.take().unwrap(); let mut shell_stdout = match shell.stdout.take() {
let mut shell_stderr = shell.stderr.take().unwrap(); Some(s) => s,
None => {
tracing::error!("stdout pipe unavailable for channel={:?}", channel_id);
return Err(russh::Error::IO(io::Error::new(io::ErrorKind::Other, "stdout unavailable")));
}
};
let mut shell_stderr = match shell.stderr.take() {
Some(s) => s,
None => {
tracing::error!("stderr pipe unavailable for channel={:?}", channel_id);
return Err(russh::Error::IO(io::Error::new(io::ErrorKind::Other, "stderr unavailable")));
}
};
let (eof_tx, mut eof_rx) = tokio::sync::mpsc::channel::<bool>(10); let (eof_tx, mut eof_rx) = tokio::sync::mpsc::channel::<bool>(10);
self.eof.insert(channel_id, eof_tx); self.eof.insert(channel_id, eof_tx);

View File

@ -18,7 +18,7 @@ use crate::error::RoomError;
use crate::metrics::RoomMetrics; use crate::metrics::RoomMetrics;
use crate::types::NotificationEvent; use crate::types::NotificationEvent;
const BROADCAST_CAPACITY: usize = 100_000; const BROADCAST_CAPACITY: usize = 1000;
const SHUTDOWN_CHANNEL_CAPACITY: usize = 16; const SHUTDOWN_CHANNEL_CAPACITY: usize = 16;
const CONNECTION_COOLDOWN: Duration = Duration::from_secs(30); const CONNECTION_COOLDOWN: Duration = Duration::from_secs(30);
const MAX_CONNECTIONS_PER_ROOM: usize = 50000; const MAX_CONNECTIONS_PER_ROOM: usize = 50000;
@ -689,18 +689,36 @@ impl RoomConnectionManager {
/// Load all active typing entries for a room from Redis and return as TypingEvents. /// Load all active typing entries for a room from Redis and return as TypingEvents.
/// Used to replay current typing state to newly connected WS clients. /// Used to replay current typing state to newly connected WS clients.
/// Uses SCAN instead of KEYS to avoid blocking the Redis server.
pub async fn get_active_typing_events(&self, room_id: Uuid) -> Vec<TypingEvent> { pub async fn get_active_typing_events(&self, room_id: Uuid) -> Vec<TypingEvent> {
let pattern = format!("typing:{}:*", room_id); let pattern = format!("typing:{}:*", room_id);
if let Ok(mut conn) = self.cache.conn().await { if let Ok(mut conn) = self.cache.conn().await {
let keys: Vec<String> = match redis::cmd("KEYS").arg(&pattern).query_async(&mut conn).await { let mut cursor: u64 = 0;
Ok(k) => k, let mut all_keys: Vec<String> = Vec::new();
Err(_) => return vec![], loop {
}; let (next_cursor, keys): (u64, Vec<String>) = match redis::cmd("SCAN")
if keys.is_empty() { .arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await
{
Ok(r) => r,
Err(_) => return vec![],
};
all_keys.extend(keys);
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
if all_keys.is_empty() {
return vec![]; return vec![];
} }
let mut results = Vec::new(); let mut results = Vec::new();
for key in keys { for key in all_keys {
let parts: Vec<&str> = key.split(':').collect(); let parts: Vec<&str> = key.split(':').collect();
let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok()); let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok());
if let (Some(value), Some(user_uuid)) = ( if let (Some(value), Some(user_uuid)) = (
@ -861,7 +879,9 @@ pub fn make_persist_fn(
&batch_sql, &batch_sql,
vec![], vec![],
); );
let _ = db.execute_raw(stmt).await; if let Err(e) = db.execute_raw(stmt).await {
tracing::warn!(error = %e, "full text index update failed");
}
} }
metrics.messages_persisted.increment(count); metrics.messages_persisted.increment(count);

View File

@ -212,10 +212,13 @@ impl RoomService {
seq: None, seq: None,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
let _ = self if let Err(e) = self
.queue .queue
.publish_project_room_event(project.id, event) .publish_project_room_event(project.id, event)
.await; .await
{
tracing::warn!(error = %e, "failed to publish room created event");
}
self.notify_project_members( self.notify_project_members(
project.id, project.id,
@ -290,10 +293,13 @@ impl RoomService {
seq: None, seq: None,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
let _ = self if let Err(e) = self
.queue .queue
.publish_project_room_event(updated.project, event) .publish_project_room_event(updated.project, event)
.await; .await
{
tracing::warn!(error = %e, "failed to publish room event");
}
} }
if moved { if moved {
let event = ProjectRoomEvent { let event = ProjectRoomEvent {
@ -305,10 +311,13 @@ impl RoomService {
seq: None, seq: None,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
let _ = self if let Err(e) = self
.queue .queue
.publish_project_room_event(updated.project, event) .publish_project_room_event(updated.project, event)
.await; .await
{
tracing::warn!(error = %e, "failed to publish room event");
}
} }
Ok(super::RoomResponse::from(updated)) Ok(super::RoomResponse::from(updated))
@ -378,10 +387,13 @@ impl RoomService {
seq: None, seq: None,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
let _ = self if let Err(e) = self
.queue .queue
.publish_project_room_event(project_id, event) .publish_project_room_event(project_id, event)
.await; .await
{
tracing::warn!(error = %e, "failed to publish room deleted event");
}
self.notify_project_members( self.notify_project_members(
project_id, project_id,

View File

@ -28,25 +28,58 @@ impl Drop for RoomAiLockGuard {
let lock_key = self.lock_key.clone(); let lock_key = self.lock_key.clone();
let lock_token = self.lock_token.clone(); let lock_token = self.lock_token.clone();
let request_uid = self.request_uid.clone(); let request_uid = self.request_uid.clone();
tokio::spawn(async move {
if let Err(e) = release_lock( // Use tokio::spawn if we're inside a runtime; otherwise fall back to a
&cache, // background thread so the lock is always released (not silently leaked).
&queue_key, if let Ok(handle) = tokio::runtime::Handle::try_current() {
&ticket_key, handle.spawn(async move {
&lock_key, if let Err(e) = release_lock(
&lock_token, &cache,
&request_uid, &queue_key,
) &ticket_key,
.await &lock_key,
{ &lock_token,
tracing::warn!( &request_uid,
lock_key = %lock_key, )
lock_token = %lock_token, .await
error = %e, {
"RoomAiLockGuard: failed to release lock" tracing::warn!(
); lock_key = %lock_key,
} lock_token = %lock_token,
}); error = %e,
"RoomAiLockGuard: failed to release lock"
);
}
});
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok();
if let Some(rt) = rt {
rt.block_on(async {
if let Err(e) = release_lock(
&cache,
&queue_key,
&ticket_key,
&lock_key,
&lock_token,
&request_uid,
)
.await
{
tracing::warn!(
lock_key = %lock_key,
lock_token = %lock_token,
error = %e,
"RoomAiLockGuard: failed to release lock (fallback thread)"
);
}
});
}
});
}
} }
} }

View File

@ -46,6 +46,7 @@ pub async fn process_message_ai_react_streaming(
let _lock_guard = lock_guard; let _lock_guard = lock_guard;
// Collect ordered steps for storage and streaming. // Collect ordered steps for storage and streaming.
// Using poison-recovering guards to prevent Mutex poisoning from killing the room.
let steps: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = let steps: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let last_action_name: std::sync::Arc<std::sync::Mutex<String>> = let last_action_name: std::sync::Arc<std::sync::Mutex<String>> =
@ -54,6 +55,11 @@ pub async fn process_message_ai_react_streaming(
std::sync::Arc::new(std::sync::Mutex::new(String::new())); std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let step_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let step_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
// Helper: recover from poison instead of panicking.
fn lock_or_recover<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
let on_step = { let on_step = {
let room_manager = room_manager.clone(); let room_manager = room_manager.clone();
let streaming_msg_id = streaming_msg_id; let streaming_msg_id = streaming_msg_id;
@ -70,15 +76,14 @@ pub async fn process_message_ai_react_streaming(
("thinking".to_string(), format!("[Thinking] {}", thought)) ("thinking".to_string(), format!("[Thinking] {}", thought))
} }
ReactStep::Action { step: _, action } => { ReactStep::Action { step: _, action } => {
*last_action_name.lock().unwrap() = action.name.clone(); *lock_or_recover(&last_action_name) = action.name.clone();
("tool_call".to_string(), format!("[Action] Calling `{}` with {:?}", action.name, action.args)) ("tool_call".to_string(), format!("[Action] Calling `{}` with {:?}", action.name, action.args))
} }
ReactStep::Observation { ReactStep::Observation {
step: _, step: _,
observation: _, observation: _,
} => { } => {
// Sanitize observation — don't expose raw tool output to frontend let action_name = lock_or_recover(&last_action_name).clone();
let action_name = last_action_name.lock().unwrap().clone();
("tool_call".to_string(), format!("[Observation] {} (completed)", action_name)) ("tool_call".to_string(), format!("[Observation] {} (completed)", action_name))
} }
ReactStep::Answer { step: _, answer } => { ReactStep::Answer { step: _, answer } => {
@ -93,11 +98,11 @@ pub async fn process_message_ai_react_streaming(
// Record ordered step for storage // Record ordered step for storage
{ {
let mut s = steps.lock().unwrap(); let mut s = lock_or_recover(&steps);
s.push((chunk_type.clone(), content.clone())); s.push((chunk_type.clone(), content.clone()));
} }
if is_answer { if is_answer {
let mut ab = answer_buffer.lock().unwrap(); let mut ab = lock_or_recover(&answer_buffer);
ab.push_str(&content); ab.push_str(&content);
} }
@ -120,8 +125,8 @@ pub async fn process_message_ai_react_streaming(
let result = chat_service.process_react(&request, on_step).await; let result = chat_service.process_react(&request, on_step).await;
let final_content = answer_buffer.lock().unwrap().clone(); let final_content = lock_or_recover(&answer_buffer).clone();
let all_steps = steps.lock().unwrap().clone(); let all_steps = lock_or_recover(&steps).clone();
let reasoning_chain: String = all_steps let reasoning_chain: String = all_steps
.iter() .iter()
.filter(|(t, _)| t != "answer") .filter(|(t, _)| t != "answer")
@ -168,7 +173,7 @@ pub async fn process_message_ai_react_streaming(
// Serialize ordered steps as JSON for ordered replay. // Serialize ordered steps as JSON for ordered replay.
let thinking_content = { let thinking_content = {
let steps = steps.lock().unwrap(); let steps = lock_or_recover(&steps);
if steps.is_empty() { if steps.is_empty() {
None None
} else { } else {

View File

@ -198,7 +198,9 @@ where
.publish_agent_task_event(project_id, started_event) .publish_agent_task_event(project_id, started_event)
.await; .await;
let _ = task_service.start(task_id).await; if let Err(e) = task_service.start(task_id).await {
tracing::warn!(error = %e, task_id = %task_id, "AI task start failed");
}
let queue_clone = queue.clone(); let queue_clone = queue.clone();
let room_manager_clone = room_manager.clone(); let room_manager_clone = room_manager.clone();
@ -211,7 +213,9 @@ where
let event = match result { let event = match result {
Ok(output) => { Ok(output) => {
let _ = task_service.complete(task_id, &output).await; if let Err(e) = task_service.complete(task_id, &output).await {
tracing::warn!(error = %e, task_id = %task_id, "AI task complete failed");
}
AgentTaskEvent { AgentTaskEvent {
task_id, task_id,
project_id, project_id,
@ -225,7 +229,9 @@ where
} }
} }
Err(err) => { Err(err) => {
let _ = task_service.fail(task_id, &err).await; if let Err(e) = task_service.fail(task_id, &err).await {
tracing::warn!(error = %e, task_id = %task_id, "AI task fail failed");
}
AgentTaskEvent { AgentTaskEvent {
task_id, task_id,
project_id, project_id,

View File

@ -164,7 +164,7 @@ fn capability_list(model: &UpstreamModel) -> Vec<(CapabilityType, bool)> {
// Provider helpers ----------------------------------------------------------- // Provider helpers -----------------------------------------------------------
fn extract_provider_name(model: &UpstreamModel) -> &str { fn extract_provider_name(model: &UpstreamModel) -> String {
if let Some(owned) = &model.owned_by { if let Some(owned) = &model.owned_by {
if !owned.is_empty() { if !owned.is_empty() {
return normalize_provider_name(owned); return normalize_provider_name(owned);
@ -173,21 +173,21 @@ fn extract_provider_name(model: &UpstreamModel) -> &str {
normalize_provider_name(model.id.split('/').next().unwrap_or("unknown")) normalize_provider_name(model.id.split('/').next().unwrap_or("unknown"))
} }
fn normalize_provider_name(slug: &str) -> &'static str { fn normalize_provider_name(slug: &str) -> String {
match slug { match slug {
"openai" => "openai", "openai" => "openai".to_string(),
"anthropic" => "anthropic", "anthropic" => "anthropic".to_string(),
"google" | "google-ai" => "google", "google" | "google-ai" => "google".to_string(),
"mistralai" => "mistral", "mistralai" => "mistral".to_string(),
"meta-llama" | "meta" => "meta", "meta-llama" | "meta" => "meta".to_string(),
"deepseek" => "deepseek", "deepseek" => "deepseek".to_string(),
"azure" | "azure-openai" => "azure", "azure" | "azure-openai" => "azure".to_string(),
"x-ai" | "xai" => "xai", "x-ai" | "xai" => "xai".to_string(),
"moonshot" => "moonshot", "moonshot" => "moonshot".to_string(),
"zai" => "zai", "zai" => "zai".to_string(),
"minimax" => "minimax", "minimax" => "minimax".to_string(),
"alibaba" | "qwen" => "qwen", "alibaba" | "qwen" => "qwen".to_string(),
s => Box::leak(s.to_string().into_boxed_str()), s => s.to_string(),
} }
} }
@ -223,12 +223,7 @@ async fn upsert_provider(db: &AppDatabase, slug: &str) -> Result<ProviderModel,
{ {
let mut active: models::agents::model_provider::ActiveModel = existing.into(); let mut active: models::agents::model_provider::ActiveModel = existing.into();
active.updated_at = Set(now); active.updated_at = Set(now);
active.update(db).await?; active.update(db).await
Ok(ProviderEntity::find()
.filter(PCol::Name.eq(slug))
.one(db)
.await?
.unwrap())
} else { } else {
let active = models::agents::model_provider::ActiveModel { let active = models::agents::model_provider::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),
@ -266,11 +261,8 @@ async fn upsert_model(
active.max_output_tokens = Set(max_out); active.max_output_tokens = Set(max_out);
active.status = Set(ModelStatus::Active.to_string()); active.status = Set(ModelStatus::Active.to_string());
active.updated_at = Set(now); active.updated_at = Set(now);
active.update(db).await?; let updated = active.update(db).await?;
Ok(( Ok((updated, false))
ModelEntity::find_by_id(existing.id).one(db).await?.unwrap(),
false,
))
} else { } else {
let active = models::agents::model::ActiveModel { let active = models::agents::model::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),

View File

@ -19,7 +19,9 @@ impl AppService {
pub async fn git_init_bare( pub async fn git_init_bare(
&self, &self,
request: GitInitRequest, request: GitInitRequest,
ctx: &Session,
) -> Result<GitInitResponse, AppError> { ) -> Result<GitInitResponse, AppError> {
let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let domain = git::GitDomain::init_bare(&request.path).map_err(AppError::from)?; let domain = git::GitDomain::init_bare(&request.path).map_err(AppError::from)?;
Ok(GitInitResponse { Ok(GitInitResponse {
path: domain.repo().path().to_string_lossy().to_string(), path: domain.repo().path().to_string_lossy().to_string(),
@ -27,7 +29,8 @@ impl AppService {
}) })
} }
pub async fn git_open(&self, path: String) -> Result<GitInitResponse, AppError> { pub async fn git_open(&self, path: String, ctx: &Session) -> Result<GitInitResponse, AppError> {
let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let domain = git::GitDomain::open(&path).map_err(AppError::from)?; let domain = git::GitDomain::open(&path).map_err(AppError::from)?;
Ok(GitInitResponse { Ok(GitInitResponse {
path: domain.repo().path().to_string_lossy().to_string(), path: domain.repo().path().to_string_lossy().to_string(),
@ -35,7 +38,8 @@ impl AppService {
}) })
} }
pub async fn git_open_workdir(&self, path: String) -> Result<GitInitResponse, AppError> { pub async fn git_open_workdir(&self, path: String, ctx: &Session) -> Result<GitInitResponse, AppError> {
let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let domain = git::GitDomain::open_workdir(&path).map_err(AppError::from)?; let domain = git::GitDomain::open_workdir(&path).map_err(AppError::from)?;
Ok(GitInitResponse { Ok(GitInitResponse {
path: domain.repo().path().to_string_lossy().to_string(), path: domain.repo().path().to_string_lossy().to_string(),
@ -43,13 +47,12 @@ impl AppService {
}) })
} }
pub async fn git_is_repo(&self, path: String) -> Result<bool, AppError> { pub async fn git_is_repo(&self, path: String, ctx: &Session) -> Result<bool, AppError> {
let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
match git::GitDomain::open(&path) { match git::GitDomain::open(&path) {
Ok(_) => Ok(true), Ok(_) => Ok(true),
Err(git::GitError::NotFound(_)) => Ok(false), Err(git::GitError::NotFound(_)) => Ok(false),
Err(git::GitError::IoError(_)) => Ok(false), Err(git::GitError::IoError(_)) => Ok(false),
// Other errors (permission denied, corruption, etc.) indicate an abnormal
// state that the caller should be aware of.
Err(e) => Err(AppError::from(e)), Err(e) => Err(AppError::from(e)),
} }
} }

View File

@ -262,7 +262,7 @@ impl AppService {
.flatten() .flatten()
.map(|u| u.username) .map(|u| u.username)
.unwrap_or_default(); .unwrap_or_default();
let _ = self if let Err(e) = self
.project_log_activity( .project_log_activity(
project.id, project.id,
None, None,
@ -278,7 +278,10 @@ impl AppService {
is_private: false, is_private: false,
}, },
) )
.await; .await
{
tracing::warn!(error = %e, "failed to log issue open activity");
}
// Run AI triage asynchronously // Run AI triage asynchronously
let project_name_clone = project_name.clone(); let project_name_clone = project_name.clone();
@ -342,7 +345,7 @@ impl AppService {
.flatten() .flatten()
.map(|u| u.username) .map(|u| u.username)
.unwrap_or_default(); .unwrap_or_default();
let _ = self if let Err(e) = self
.project_log_activity( .project_log_activity(
project.id, project.id,
None, None,
@ -358,7 +361,10 @@ impl AppService {
is_private: false, is_private: false,
}, },
) )
.await; .await
{
tracing::warn!(error = %e, "failed to log issue update activity");
}
Ok(IssueResponse::from(model)) Ok(IssueResponse::from(model))
} }
@ -438,7 +444,7 @@ impl AppService {
} else { } else {
"issue_reopen" "issue_reopen"
}; };
let _ = self if let Err(e) = self
.project_log_activity( .project_log_activity(
project.id, project.id,
None, None,
@ -463,7 +469,10 @@ impl AppService {
is_private: false, is_private: false,
}, },
) )
.await; .await
{
tracing::warn!(error = %e, "failed to log issue state change activity");
}
Ok(IssueResponse::from(model)) Ok(IssueResponse::from(model))
} }
@ -502,31 +511,33 @@ impl AppService {
return Err(AppError::NoPower); return Err(AppError::NoPower);
} }
// Cascade delete related records // Cascade delete related records within a transaction to prevent orphaned records.
let txn = self.db.begin().await?;
issue_comment::Entity::delete_many() issue_comment::Entity::delete_many()
.filter(issue_comment::Column::Issue.eq(issue.id)) .filter(issue_comment::Column::Issue.eq(issue.id))
.exec(&self.db) .exec(&txn)
.await?; .await?;
issue_assignee::Entity::delete_many() issue_assignee::Entity::delete_many()
.filter(issue_assignee::Column::Issue.eq(issue.id)) .filter(issue_assignee::Column::Issue.eq(issue.id))
.exec(&self.db) .exec(&txn)
.await?; .await?;
issue_label::Entity::delete_many() issue_label::Entity::delete_many()
.filter(issue_label::Column::Issue.eq(issue.id)) .filter(issue_label::Column::Issue.eq(issue.id))
.exec(&self.db) .exec(&txn)
.await?; .await?;
issue_subscriber::Entity::delete_many() issue_subscriber::Entity::delete_many()
.filter(issue_subscriber::Column::Issue.eq(issue.id)) .filter(issue_subscriber::Column::Issue.eq(issue.id))
.exec(&self.db) .exec(&txn)
.await?; .await?;
issue_repo::Entity::delete_many() issue_repo::Entity::delete_many()
.filter(issue_repo::Column::Issue.eq(issue.id)) .filter(issue_repo::Column::Issue.eq(issue.id))
.exec(&self.db) .exec(&txn)
.await?; .await?;
issue::Entity::delete_by_id((issue.id, issue.number)) issue::Entity::delete_by_id((issue.id, issue.number))
.exec(&self.db) .exec(&txn)
.await?; .await?;
txn.commit().await?;
self.invalidate_issue_cache(project.id, number).await; self.invalidate_issue_cache(project.id, number).await;
@ -537,7 +548,7 @@ impl AppService {
.flatten() .flatten()
.map(|u| u.username) .map(|u| u.username)
.unwrap_or_default(); .unwrap_or_default();
let _ = self if let Err(e) = self
.project_log_activity( .project_log_activity(
project.id, project.id,
None, None,
@ -553,7 +564,10 @@ impl AppService {
is_private: false, is_private: false,
}, },
) )
.await; .await
{
tracing::warn!(error = %e, "failed to log issue delete activity");
}
Ok(()) Ok(())
} }
@ -578,7 +592,11 @@ impl AppService {
.filter(issue::Column::State.eq(IssueState::Open.to_string())) .filter(issue::Column::State.eq(IssueState::Open.to_string()))
.count(&self.db) .count(&self.db)
.await?; .await?;
let closed = total - open; let closed: u64 = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::State.eq(IssueState::Closed.to_string()))
.count(&self.db)
.await?;
Ok(IssueSummaryResponse { Ok(IssueSummaryResponse {
total: total as i64, total: total as i64,

View File

@ -27,13 +27,25 @@ impl AppStorage {
}) })
} }
fn sanitize_key(key: &str) -> anyhow::Result<PathBuf> {
// Reject any key containing ".." components to prevent path traversal.
let path = PathBuf::from(key);
for component in path.components() {
if let std::path::Component::ParentDir = component {
anyhow::bail!("path traversal detected in key: {}", key);
}
}
Ok(path)
}
/// Write data to a local path and return the public URL. /// Write data to a local path and return the public URL.
pub async fn upload( pub async fn upload(
&self, &self,
key: &str, key: &str,
data: Vec<u8>, data: Vec<u8>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let path = self.base_path.join(key); let safe_path = Self::sanitize_key(key)?;
let path = self.base_path.join(safe_path);
// Create parent directories // Create parent directories
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@ -51,7 +63,8 @@ impl AppStorage {
} }
pub async fn delete(&self, key: &str) -> anyhow::Result<()> { pub async fn delete(&self, key: &str) -> anyhow::Result<()> {
let path = self.base_path.join(key); let safe_path = Self::sanitize_key(key)?;
let path = self.base_path.join(safe_path);
if path.exists() { if path.exists() {
tokio::fs::remove_file(&path).await?; tokio::fs::remove_file(&path).await?;
} }
@ -60,7 +73,8 @@ impl AppStorage {
/// Read a file by key and return (bytes, content_type). /// Read a file by key and return (bytes, content_type).
pub async fn read(&self, key: &str) -> anyhow::Result<(Vec<u8>, String)> { pub async fn read(&self, key: &str) -> anyhow::Result<(Vec<u8>, String)> {
let path = self.base_path.join(key); let safe_path = Self::sanitize_key(key)?;
let path = self.base_path.join(safe_path);
let data = tokio::fs::read(&path).await?; let data = tokio::fs::read(&path).await?;
let content_type = mime_guess2::from_path(&path) let content_type = mime_guess2::from_path(&path)
.first_or_octet_stream() .first_or_octet_stream()

View File

@ -154,13 +154,12 @@ impl AppService {
let items: Vec<WorkspaceListItem> = workspaces let items: Vec<WorkspaceListItem> = workspaces
.into_iter() .into_iter()
.map(|ws| { .filter_map(|ws| {
let membership = memberships let membership = memberships
.iter() .iter()
.find(|m| m.workspace_id == ws.id) .find(|m| m.workspace_id == ws.id)
.cloned() .cloned()?;
.unwrap(); Some(WorkspaceListItem {
WorkspaceListItem {
id: ws.id, id: ws.id,
slug: ws.slug, slug: ws.slug,
name: ws.name, name: ws.name,

View File

@ -29,6 +29,7 @@ impl AppService {
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?; .await?;
let ws_id = ws.id;
let mut m: workspace::ActiveModel = ws.into(); let mut m: workspace::ActiveModel = ws.into();
if let Some(name) = params.name { if let Some(name) = params.name {
@ -36,7 +37,7 @@ impl AppService {
if workspace::Entity::find() if workspace::Entity::find()
.filter(workspace::Column::Name.eq(&name)) .filter(workspace::Column::Name.eq(&name))
.filter(workspace::Column::DeletedAt.is_null()) .filter(workspace::Column::DeletedAt.is_null())
.filter(workspace::Column::Id.ne(m.id.clone().unwrap())) .filter(workspace::Column::Id.ne(ws_id))
.one(&self.db) .one(&self.db)
.await? .await?
.is_some() .is_some()

View File

@ -46,7 +46,7 @@ export function LoginPage() {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!form.username.trim() || !form.password) { if (!form.username.trim() || !form.password || !form.captcha.trim()) {
setError("Please fill in all required fields."); setError("Please fill in all required fields.");
return; return;
} }

View File

@ -84,7 +84,7 @@ function ProjectRoomInner() {
try { try {
await deleteRoom(activeRoom.id); await deleteRoom(activeRoom.id);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setActiveRoom(null); // deleteRoom already clears activeRoom, so no duplicate navigation needed.
} catch (err) { } catch (err) {
console.error('Failed to delete room:', err); console.error('Failed to delete room:', err);
} finally { } finally {

View File

@ -32,6 +32,16 @@ import type {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
/** Escape HTML entities to prevent XSS when rendering user content via dangerouslySetInnerHTML. */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
const ALL_TYPES = ['projects', 'repos', 'issues', 'users', 'messages'] as const; const ALL_TYPES = ['projects', 'repos', 'issues', 'users', 'messages'] as const;
type SearchType = typeof ALL_TYPES[number]; type SearchType = typeof ALL_TYPES[number];
@ -195,7 +205,7 @@ function MessageItem({ item }: { item: MessageSearchItem }) {
<p <p
className="text-xs mt-0.5 line-clamp-2 whitespace-pre-wrap" className="text-xs mt-0.5 line-clamp-2 whitespace-pre-wrap"
style={{ color: 'var(--muted-foreground)' }} style={{ color: 'var(--muted-foreground)' }}
dangerouslySetInnerHTML={{ __html: displayContent }} dangerouslySetInnerHTML={{ __html: escapeHtml(displayContent ?? '') }}
/> />
</div> </div>
</a> </a>

View File

@ -500,8 +500,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
{showPins && ( {showPins && (
<RoomPinPanel <RoomPinPanel
roomId={room.id} roomId={room.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any messages={messages as RoomMessageResponse[]}
messages={messages as any}
members={members} members={members}
onClose={() => setShowPins(false)} onClose={() => setShowPins(false)}
onJumpToMessage={handleJumpToMessage} onJumpToMessage={handleJumpToMessage}

View File

@ -107,7 +107,23 @@ export const RepositoryContextProvider = ({
}; };
}, [repoItem, namespace, starCountResp, watchCountResp]); }, [repoItem, namespace, starCountResp, watchCountResp]);
if (reposLoading) return null; if (reposLoading || !repo) {
// Return a minimal placeholder during loading to avoid unmounting
// the entire subtree (which causes state loss and flash).
const fallbackRepo: RepoInfo = {
id: '', name: '', namespace: '', display_name: '',
description: '', is_private: false, default_branch: '',
fork_count: 0, star_count: 0, watch_count: 0,
last_commit_at: new Date().toISOString(),
is_star: false, is_watch: false,
ssh_clone_url: '', https_clone_url: '', ai_code_review_enabled: false,
};
return (
<ProjectProvider projectName={namespace}>
<RepositoryContext.Provider value={fallbackRepo}>{children}</RepositoryContext.Provider>
</ProjectProvider>
);
}
return ( return (
<ProjectProvider projectName={namespace}> <ProjectProvider projectName={namespace}>

View File

@ -195,6 +195,13 @@ export function RoomProvider({
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null); const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
const wsClientRef = useRef<RoomWsClient | null>(null); const wsClientRef = useRef<RoomWsClient | null>(null);
const activeRoomIdRef = useRef<string | null>(activeRoomId); const activeRoomIdRef = useRef<string | null>(activeRoomId);
// Sync activeRoomId when initialRoomId prop changes (e.g., browser back/forward navigation).
useEffect(() => {
if (initialRoomId !== activeRoomId) {
setActiveRoomId(initialRoomId);
}
}, [initialRoomId]);
const fetchRoomAiConfigsRef = useRef<() => Promise<void>>(async () => {}); const fetchRoomAiConfigsRef = useRef<() => Promise<void>>(async () => {});
const fetchProjectReposRef = useRef<() => Promise<void>>(async () => {}); const fetchProjectReposRef = useRef<() => Promise<void>>(async () => {});
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle'); const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
@ -1003,8 +1010,12 @@ export function RoomProvider({
async (userId: string) => { async (userId: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!activeRoomId || !client) return; if (!activeRoomId || !client) return;
await client.memberRemove(activeRoomId, userId); try {
setMembers((prev) => prev.filter((m) => m.user !== userId)); await client.memberRemove(activeRoomId, userId);
setMembers((prev) => prev.filter((m) => m.user !== userId));
} catch (error) {
console.error('Failed to remove member:', error);
}
}, },
[activeRoomId], [activeRoomId],
); );
@ -1013,8 +1024,12 @@ export function RoomProvider({
async (userId: string, role: string) => { async (userId: string, role: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!activeRoomId || !client) return; if (!activeRoomId || !client) return;
await client.memberUpdateRole(activeRoomId, userId, role); try {
setMembers((prev) => prev.map((m) => (m.user === userId ? { ...m, role } : m))); await client.memberUpdateRole(activeRoomId, userId, role);
setMembers((prev) => prev.map((m) => (m.user === userId ? { ...m, role } : m)));
} catch (error) {
console.error('Failed to update member role:', error);
}
}, },
[activeRoomId], [activeRoomId],
); );
@ -1113,12 +1128,16 @@ export function RoomProvider({
await client.messageUpdate(messageId, content); await client.messageUpdate(messageId, content);
} }
} catch (err) { } catch (err) {
// Rollback: if we captured the original content, restore it.
// If the message was already gone (e.g. concurrent delete), there's nothing to rollback.
if (rollbackContent !== null) { if (rollbackContent !== null) {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m, m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m,
), ),
); );
} else {
console.warn('[editMessage] no rollback content available for', messageId);
} }
handleRoomError('Edit message', err); handleRoomError('Edit message', err);
} }
@ -1464,7 +1483,7 @@ export function RoomProvider({
wsError, wsError,
connectWs, connectWs,
disconnectWs, disconnectWs,
wsClientRef.current, wsClient,
roomsWithCategory, roomsWithCategory,
roomsLoading, roomsLoading,
roomsError, roomsError,

View File

@ -140,7 +140,14 @@ export class RoomWsClient {
/** Update callbacks (e.g. to register onNotification after construction). */ /** Update callbacks (e.g. to register onNotification after construction). */
updateCallbacks(callbacks: Partial<RoomWsCallbacks>): void { updateCallbacks(callbacks: Partial<RoomWsCallbacks>): void {
Object.assign(this.callbacks, callbacks); // Merge callbacks but skip undefined values — this prevents one hook's
// cleanup from wiping out another hook's callbacks.
for (const key of Object.keys(callbacks) as (keyof RoomWsCallbacks)[]) {
const val = callbacks[key];
if (val !== undefined) {
(this.callbacks as any)[key] = val;
}
}
} }
getWsToken(): string | null { getWsToken(): string | null {

View File

@ -28,6 +28,7 @@ export class UniversalWsClient {
private shouldReconnect = true; private shouldReconnect = true;
private subscribedRooms = new Set<string>(); private subscribedRooms = new Set<string>();
private wsToken: string | null = null; private wsToken: string | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
constructor( constructor(
baseUrl: string, baseUrl: string,
@ -59,6 +60,7 @@ export class UniversalWsClient {
ws.onopen = () => { ws.onopen = () => {
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.setStatus('open'); this.setStatus('open');
this.startHeartbeat();
// Re-subscribe to rooms after reconnect // Re-subscribe to rooms after reconnect
for (const roomId of this.subscribedRooms) { for (const roomId of this.subscribedRooms) {
@ -81,6 +83,7 @@ export class UniversalWsClient {
ws.onclose = (ev: CloseEvent) => { ws.onclose = (ev: CloseEvent) => {
this.ws = null; this.ws = null;
this.stopHeartbeat();
this.setStatus('closed'); this.setStatus('closed');
// Reject all pending requests // Reject all pending requests
@ -190,7 +193,9 @@ export class UniversalWsClient {
const baseDelay = this.options.reconnectBaseDelay ?? 1000; const baseDelay = this.options.reconnectBaseDelay ?? 1000;
const maxDelay = this.options.reconnectMaxDelay ?? 15000; const maxDelay = this.options.reconnectMaxDelay ?? 15000;
const delay = Math.min(baseDelay * 2 ** this.reconnectAttempt, maxDelay); // Exponential backoff with full jitter to prevent thundering herd.
const cap = Math.min(baseDelay * 2 ** this.reconnectAttempt, maxDelay);
const delay = Math.random() * cap;
this.reconnectAttempt++; this.reconnectAttempt++;
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
@ -200,4 +205,26 @@ export class UniversalWsClient {
}); });
}, delay); }, delay);
} }
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
// Lightweight ping — if the server doesn't support pong,
// the connection will still be validated by the readystate check.
try {
this.ws.send(JSON.stringify({ type: 'ping' }));
} catch {
// If send fails, the next cycle will detect the dead connection.
}
}
}, 30_000);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
} }