diff --git a/Cargo.lock b/Cargo.lock index b61ae4a..5bac54c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8286,6 +8286,8 @@ dependencies = [ "actix-web", "anyhow", "env_logger", + "futures", + "log", "mime", "mime_guess2", "serde", diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 9712374..5693588 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -1,7 +1,6 @@ use actix_cors::Cors; use actix_web::cookie::time::Duration; use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; -use actix_web::middleware::Logger; use actix_web::{cookie::Key, web, App, HttpResponse, HttpServer}; use clap::Parser; use db::cache::AppCache; @@ -17,6 +16,7 @@ use session::config::{PersistentSession, SessionLifecycle, TtlExtensionPolicy}; use session::storage::RedisClusterSessionStore; use session::SessionMiddleware; use std::task::{Context, Poll}; +use std::time::Instant; mod args; @@ -30,51 +30,35 @@ pub struct AppState { pub cache: AppCache, } -/// Skips Logger terminal output for noisy health/monitor/WS endpoints while still -/// passing the request through to the inner service (so metrics are still recorded). -struct SkipNoisyPaths(S); +/// Custom middleware that logs requests except for noisy paths. +struct RequestLogger; -impl SkipNoisyPaths { - fn new(logger: S) -> Self { - Self(logger) - } -} - -impl actix_web::dev::Transform for SkipNoisyPaths +impl actix_web::dev::Transform for RequestLogger where - S: actix_web::dev::Transform< - ServiceRequest, - Response = ServiceResponse, - Error = actix_web::Error, - InitError = (), - >, + S: Service, Error = actix_web::Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; - type Transform = SkipNoisyPathsService; + type Transform = RequestLoggerService; type InitError = (); + type Future = futures::future::Ready>; - fn new_transform(&self, service: S::Transform) -> , - Error = actix_web::Error, - InitError = (), - >>::Future { - futures::future::ok(SkipNoisyPathsService { + fn new_transform(&self, service: S) -> Self::Future { + futures::future::ok(RequestLoggerService { service, _marker: std::marker::PhantomData, }) } } -struct SkipNoisyPathsService { +struct RequestLoggerService { service: S, _marker: std::marker::PhantomData, } -impl actix_web::dev::Service for SkipNoisyPathsService +impl actix_web::dev::Service for RequestLoggerService where S: actix_web::dev::Service, Error = actix_web::Error>, S::Future: 'static, @@ -90,19 +74,33 @@ where fn call(&self, req: ServiceRequest) -> Self::Future { let path = req.path().to_string(); - let should_skip = path == "/health" + let method = req.method().to_string(); + let should_log = !(path == "/health" || path == "/metrics" - || path.starts_with("/ws"); - if should_skip { - let fut = self.service.call(req); - Box::pin(async move { - let res = fut.await?; - // Replace body so Logger doesn't print the line for this request. - Ok(res.map_body(|_, _| actix_web::body::Empty::new())) - }) - } else { - self.service.call(req) - } + || path.starts_with("/ws") + || path.starts_with("/assets")); + + let start = Instant::now(); + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + if should_log { + tracing::info!( + target: "http_request", + method = %method, + path = %path, + status = res.status().as_u16(), + elapsed = ?start.elapsed(), + "{} {} {} {:?}", + method, + path, + res.status().as_u16(), + start.elapsed() + ); + } + Ok(res) + }) } } @@ -207,7 +205,7 @@ async fn main() -> anyhow::Result<()> { App::new() .wrap(cors) .wrap(session_mw) - .wrap(SkipNoisyPaths::new(Logger::default())) + .wrap(RequestLogger) .wrap(metrics_mw) .wrap(TracingSpanMiddleware::new()) .app_data(web::Data::new(AppState { diff --git a/apps/static/Cargo.toml b/apps/static/Cargo.toml index fe1d52e..f721c22 100644 --- a/apps/static/Cargo.toml +++ b/apps/static/Cargo.toml @@ -8,6 +8,7 @@ actix-web = { workspace = true } actix-files = { workspace = true } actix-cors = { workspace = true } tokio = { workspace = true, features = ["full"] } +futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } mime = { workspace = true } @@ -15,6 +16,7 @@ mime_guess2 = { workspace = true } slog = { workspace = true } anyhow = { workspace = true } env_logger = { workspace = true } +log = "0.4" [profile.release] strip = true diff --git a/apps/static/src/main.rs b/apps/static/src/main.rs index 876b9ed..fb0f39d 100644 --- a/apps/static/src/main.rs +++ b/apps/static/src/main.rs @@ -1,10 +1,12 @@ use actix_cors::Cors; use actix_files::Files; use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; -use actix_web::{http::header, middleware::Logger, web, App, HttpResponse, HttpServer}; +use actix_web::{http::header, web, App, HttpResponse, HttpServer}; use futures::future::LocalBoxFuture; +use log::info; use std::path::PathBuf; use std::task::{Context, Poll}; +use std::time::Instant; /// Static file server for avatar, blob, and other static files /// Serves files from /data/{type} directories @@ -42,50 +44,35 @@ async fn health() -> HttpResponse { })) } -/// Skips Logger terminal output for noisy health/WS endpoints. -struct SkipNoisyPaths(S); +/// Custom middleware that logs requests except for noisy paths (health, metrics, static files). +struct RequestLogger; -impl SkipNoisyPaths { - fn new(logger: S) -> Self { - Self(logger) - } -} - -impl actix_web::dev::Transform for SkipNoisyPaths +impl actix_web::dev::Transform for RequestLogger where - S: actix_web::dev::Transform< - ServiceRequest, - Response = ServiceResponse, - Error = actix_web::Error, - InitError = (), - >, + S: Service, Error = actix_web::Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; - type Transform = SkipNoisyPathsService; + type Transform = RequestLoggerService; type InitError = (); + type Future = futures::future::Ready>; - fn new_transform(&self, service: S::Transform) -> , - Error = actix_web::Error, - InitError = (), - >>::Future { - futures::future::ok(SkipNoisyPathsService { + fn new_transform(&self, service: S) -> Self::Future { + futures::future::ok(RequestLoggerService { service, _marker: std::marker::PhantomData, }) } } -struct SkipNoisyPathsService { +struct RequestLoggerService { service: S, _marker: std::marker::PhantomData, } -impl Service for SkipNoisyPathsService +impl Service for RequestLoggerService where S: Service, Error = actix_web::Error>, S::Future: 'static, @@ -101,17 +88,32 @@ where fn call(&self, req: ServiceRequest) -> Self::Future { let path = req.path().to_string(); - let should_skip = - path == "/health" || path == "/metrics" || path.starts_with("/ws"); - if should_skip { - let fut = self.service.call(req); - Box::pin(async move { - let res = fut.await?; - Ok(res.map_body(|_, _| actix_web::body::Empty::new())) - }) - } else { - self.service.call(req) - } + let method = req.method().to_string(); + let should_log = !(path == "/health" + || path == "/metrics" + || path.starts_with("/ws") + || path.starts_with("/avatar") + || path.starts_with("/blob") + || path.starts_with("/media") + || path.starts_with("/static")); + + let start = Instant::now(); + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + if should_log { + info!( + target: "static_server", + "{} {} {} {:?}", + method, + path, + res.status().as_u16(), + start.elapsed() + ); + } + Ok(res) + }) } } @@ -155,7 +157,7 @@ async fn main() -> anyhow::Result<()> { App::new() .wrap(cors) - .wrap(SkipNoisyPaths::new(Logger::default())) + .wrap(RequestLogger) .route("/health", web::get().to(health)) .service( Files::new("/avatar", root.join("avatar")) diff --git a/libs/api/lib.rs b/libs/api/lib.rs index 0e5aa0f..3b7c9a2 100644 --- a/libs/api/lib.rs +++ b/libs/api/lib.rs @@ -7,6 +7,7 @@ pub mod issue; pub mod openapi; pub mod project; pub mod pull_request; +pub mod robots; pub mod room; pub mod route; pub mod search; diff --git a/libs/api/project/avatar.rs b/libs/api/project/avatar.rs new file mode 100644 index 0000000..9ae7c89 --- /dev/null +++ b/libs/api/project/avatar.rs @@ -0,0 +1,82 @@ +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Result, web}; +use futures_util::StreamExt; +use service::AppService; +use session::Session; + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct AvatarUploadResponse { + pub avatar_url: String, +} + +/// Upload a project's avatar. +/// Accepts a multipart form with a single file field. +#[utoipa::path( + post, + path = "/api/projects/{project_name}/avatar", + params( + ("project_name" = String, Path, description = "Project name"), + ), + responses( + (status = 200, description = "Avatar uploaded", body = crate::ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "No permission"), + ), + tag = "Project" +)] +pub async fn upload_project_avatar( + service: web::Data, + session: Session, + path: web::Path, + mut payload: Multipart, +) -> Result { + let project_name = path.into_inner(); + let max_size: usize = 2 * 1024 * 1024; // 2MB + + let mut file_data: Vec = Vec::new(); + let mut file_ext = "png".to_string(); + + while let Some(item) = payload.next().await { + let mut field = item.map_err(|e| { + crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) + })?; + + // Detect file extension from content-type + if let Some(content_type) = field.content_type() { + let ext = match content_type.essence_str() { + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/png" | _ => "png", + }; + file_ext = ext.to_string(); + } + + while let Some(chunk) = field.next().await { + let data = chunk.map_err(|e| { + crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) + })?; + if file_data.len() + data.len() > max_size { + return Err(crate::error::ApiError( + service::error::AppError::BadRequest( + "File exceeds maximum size of 2MB".to_string(), + ), + )); + } + file_data.extend_from_slice(&data); + } + } + + if file_data.is_empty() { + return Err(crate::error::ApiError( + service::error::AppError::BadRequest("No file provided".to_string()), + )); + } + + let avatar_url = service + .project_avatar_upload(&session, project_name, file_data, file_ext) + .await + .map_err(crate::error::ApiError::from)?; + + Ok(crate::ApiResponse::ok(AvatarUploadResponse { avatar_url }).to_response()) +} diff --git a/libs/api/project/mod.rs b/libs/api/project/mod.rs index 94fd257..dfe82f2 100644 --- a/libs/api/project/mod.rs +++ b/libs/api/project/mod.rs @@ -1,5 +1,6 @@ pub mod activity; pub mod audit; +pub mod avatar; pub mod billing; pub mod board; pub mod info; @@ -168,6 +169,10 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) { "/{project_name}/activities", web::post().to(activity::project_log_activity), ) + .route( + "/{project_name}/avatar", + web::post().to(avatar::upload_project_avatar), + ) .route( "/{project_name}/billing", web::get().to(billing::project_billing), diff --git a/libs/api/robots.rs b/libs/api/robots.rs new file mode 100644 index 0000000..e69de29 diff --git a/libs/api/room/ws_universal.rs b/libs/api/room/ws_universal.rs index 00504b2..64be695 100644 --- a/libs/api/room/ws_universal.rs +++ b/libs/api/room/ws_universal.rs @@ -410,6 +410,7 @@ pub async fn ws_universal( username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()), avatar_url: None, action: action.to_string(), + sender_type: None, }; manager.broadcast_typing(room_id, typing_event).await; } diff --git a/libs/api/user/avatar.rs b/libs/api/user/avatar.rs new file mode 100644 index 0000000..756c2c1 --- /dev/null +++ b/libs/api/user/avatar.rs @@ -0,0 +1,76 @@ +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Result, web}; +use futures_util::StreamExt; +use service::AppService; +use session::Session; + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct AvatarUploadResponse { + pub avatar_url: String, +} + +/// Upload current user's avatar. +/// Accepts a multipart form with a single file field. +#[utoipa::path( + post, + path = "/api/users/me/avatar", + responses( + (status = 200, description = "Avatar uploaded", body = crate::ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "User" +)] +pub async fn upload_avatar( + service: web::Data, + session: Session, + mut payload: Multipart, +) -> Result { + let max_size: usize = 2 * 1024 * 1024; // 2MB + + let mut file_data: Vec = Vec::new(); + let mut file_ext = "png".to_string(); + + while let Some(item) = payload.next().await { + let mut field = item.map_err(|e| { + crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) + })?; + + // Detect file extension from content-type + if let Some(content_type) = field.content_type() { + let ext = match content_type.essence_str() { + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/png" | _ => "png", + }; + file_ext = ext.to_string(); + } + + while let Some(chunk) = field.next().await { + let data = chunk.map_err(|e| { + crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) + })?; + if file_data.len() + data.len() > max_size { + return Err(crate::error::ApiError( + service::error::AppError::BadRequest( + "File exceeds maximum size of 2MB".to_string(), + ), + )); + } + file_data.extend_from_slice(&data); + } + } + + if file_data.is_empty() { + return Err(crate::error::ApiError( + service::error::AppError::BadRequest("No file provided".to_string()), + )); + } + + let avatar_url = service + .user_avatar_upload(session, file_data, &file_ext) + .await + .map_err(crate::error::ApiError::from)?; + + Ok(crate::ApiResponse::ok(AvatarUploadResponse { avatar_url }).to_response()) +} diff --git a/libs/api/user/mod.rs b/libs/api/user/mod.rs index f2b946a..6ff2a40 100644 --- a/libs/api/user/mod.rs +++ b/libs/api/user/mod.rs @@ -1,4 +1,5 @@ pub mod access_key; +pub mod avatar; pub mod chpc; pub mod notification; pub mod preferences; @@ -18,6 +19,7 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) { web::scope("/users") .route("/me/profile", web::get().to(profile::get_my_profile)) .route("/me/profile", web::patch().to(profile::update_my_profile)) + .route("/me/avatar", web::post().to(avatar::upload_avatar)) .route( "/me/preferences", web::get().to(preferences::get_preferences), diff --git a/libs/room/src/connection.rs b/libs/room/src/connection.rs index 7c6ef19..34b7351 100644 --- a/libs/room/src/connection.rs +++ b/libs/room/src/connection.rs @@ -703,7 +703,7 @@ impl RoomConnectionManager { for key in keys { let parts: Vec<&str> = key.split(':').collect(); let user_id = parts.get(2).and_then(|s| Uuid::parse_str(s).ok()); - if let (Some(Ok(value)), Some(Ok(user_uuid)))) = ( + if let (Some(value), Some(user_uuid)) = ( redis::cmd("GET").arg(&key).query_async::(&mut conn).await.ok(), user_id, ) { diff --git a/libs/service/project/avatar.rs b/libs/service/project/avatar.rs index e029f6a..802d850 100644 --- a/libs/service/project/avatar.rs +++ b/libs/service/project/avatar.rs @@ -15,7 +15,7 @@ impl AppService { project_name: String, file: Vec, file_ext: String, - ) -> Result<(), AppError> { + ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self .utils_find_project_by_name(project_name.clone()) @@ -26,14 +26,18 @@ impl AppService { if role == MemberRole::Member { return Err(AppError::NoPower); } - let time = Utc::now(); + let time = Utc::now().timestamp(); let file_name = format!("{}-{}", project.id, time); self.avatar .upload(file, file_name.clone(), &file_ext) .await .map_err(|e| AppError::AvatarUploadError(e.to_string()))?; let static_url = self.config.static_domain().unwrap_or("/static".to_string()); - let file_url = format!("{}/{}", static_url, file_name); + let file_url = format!( + "{}/avatar/{}", + static_url.trim_end_matches('/'), + format!("{}.{}", file_name, file_ext) + ); project::Entity::update_many() .filter(project::Column::Id.eq(project.id)) .col_expr(project::Column::AvatarUrl, Expr::value(file_url.clone())) @@ -54,6 +58,6 @@ impl AppService { ctx, ) .await?; - Ok(()) + Ok(file_url) } } diff --git a/libs/service/user/avatar.rs b/libs/service/user/avatar.rs index dcc29d7..733f61e 100644 --- a/libs/service/user/avatar.rs +++ b/libs/service/user/avatar.rs @@ -30,7 +30,7 @@ impl AppService { .static_domain() .unwrap_or_else(|_| "/static".to_string()); let file_url = format!( - "{}/{}", + "{}/avatar/{}", static_url.trim_end_matches('/'), format!("{}.{}", file_name, file_ext) ); diff --git a/src/app/project/settings/general.tsx b/src/app/project/settings/general.tsx index 1c75330..9cc1f7a 100644 --- a/src/app/project/settings/general.tsx +++ b/src/app/project/settings/general.tsx @@ -1,5 +1,6 @@ +import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Upload, User } from 'lucide-react'; import { toast } from 'sonner'; import { AlertDialog, @@ -30,12 +31,51 @@ export function SettingsGeneral() { const [displayName, setDisplayName] = useState(project?.display_name || ''); const [description, setDescription] = useState(project?.description || ''); const [isPublic, setIsPublic] = useState(project?.is_public ?? false); + const [avatarUrl, setAvatarUrl] = useState(project?.avatar_url ?? ''); const hasChanges = displayName !== (project?.display_name || '') || description !== (project?.description || '') || isPublic !== (project?.is_public ?? false); + const uploadAvatarMutation = useMutation({ + mutationFn: async (file: File) => { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; + const formData = new FormData(); + formData.append('file', file); + const res = await fetch(`${baseUrl}/api/projects/${project?.name}/avatar`, { + method: 'POST', + body: formData, + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message ?? 'Upload failed'); + } + const json = await res.json(); + return json as { data?: { avatar_url?: string } }; + }, + onSuccess: (data) => { + toast.success('Project avatar updated'); + const url = data?.data?.avatar_url; + if (url) setAvatarUrl(url); + refetch(); + }, + onError: (err: Error) => { + toast.error(err.message || 'Failed to upload avatar'); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 2 * 1024 * 1024) { + toast.error('File size must be less than 2MB'); + return; + } + uploadAvatarMutation.mutate(file); + }; + const handleSave = async () => { if (!project?.name) return; @@ -80,6 +120,47 @@ export function SettingsGeneral() { +
+ +
+
+ {avatarUrl ? ( + Project avatar { e.currentTarget.style.display = 'none'; }} + /> + ) : ( + + )} + {uploadAvatarMutation.isPending && ( +
+ +
+ )} +
+ + + PNG, JPG, GIF or WebP. Max 2MB. +
+
+
{ - // This would need an actual avatar upload API - // For now, we'll just use the data URL directly - return { data: { avatar_url: `data:image/png;base64,${data.image_data}` } }; - }, - onSuccess: (data: unknown) => { - toast.success('Avatar uploaded successfully'); - const url = (data as { data?: { avatar_url?: string } })?.data?.avatar_url; - if (url) setAvatarUrl(url); - queryClient.invalidateQueries({ queryKey: ['userProfile'] }); - }, - onError: () => { - toast.error('Failed to upload avatar'); - }, - }); + const uploadAvatarMutation = useMutation({ + mutationFn: async (file: File) => { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; + const formData = new FormData(); + formData.append('file', file); + const res = await fetch(`${baseUrl}/api/users/me/avatar`, { + method: 'POST', + body: formData, + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message ?? 'Upload failed'); + } + const json = await res.json(); + return json as { data?: { avatar_url?: string } }; + }, + onSuccess: (data) => { + toast.success('Avatar uploaded successfully'); + const url = data?.data?.avatar_url; + if (url) setAvatarUrl(url); + queryClient.invalidateQueries({ queryKey: ['userProfile'] }); + }, + onError: (err: Error) => { + toast.error(err.message || 'Failed to upload avatar'); + }, + }); - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; - if (file.size > 2 * 1024 * 1024) { - toast.error('File size must be less than 2MB'); - return; - } + if (file.size > 2 * 1024 * 1024) { + toast.error('File size must be less than 2MB'); + return; + } - const reader = new FileReader(); - reader.onloadend = () => { - const base64String = reader.result as string; - const base64Data = base64String.split(',')[1]; - const format = file.type.split('/')[1] || 'png'; - - uploadAvatarMutation.mutate({ - image_data: base64Data, - format: format, - }); - }; - reader.readAsDataURL(file); - }; + uploadAvatarMutation.mutate(file); + }; const updateProfileMutation = useMutation({ mutationFn: async () => {