fix(avatar): add upload API routes and fix URL path prefix
- Add /api/users/me/avatar and /api/projects/{name}/avatar multipart upload endpoints
- Fix avatar URL path: missing /avatar prefix (static.gitdata.ai/avatar/{file})
- Fix project avatar: Utc::now() → .timestamp(), missing extension, wrong return type
- Replace broken SkipNoisyPaths middleware with self-contained RequestLogger
(actix-web 4.13 body type incompatibility with newer actix-http)
- Exclude /assets/* requests from main app logger
- Exclude /avatar/*, /blob/*, /media/*, /static/* from static server logger
- Fix TypingEvent missing sender_type field in ws_universal.rs and connection.rs
- Wire real fetch-based upload in user profile settings
- Add project avatar upload UI to project settings page
This commit is contained in:
parent
b00d42ee8d
commit
8b47f677bb
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -8286,6 +8286,8 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess2",
|
"mime_guess2",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::cookie::time::Duration;
|
use actix_web::cookie::time::Duration;
|
||||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
|
use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
|
||||||
use actix_web::middleware::Logger;
|
|
||||||
use actix_web::{cookie::Key, web, App, HttpResponse, HttpServer};
|
use actix_web::{cookie::Key, web, App, HttpResponse, HttpServer};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use db::cache::AppCache;
|
use db::cache::AppCache;
|
||||||
@ -17,6 +16,7 @@ use session::config::{PersistentSession, SessionLifecycle, TtlExtensionPolicy};
|
|||||||
use session::storage::RedisClusterSessionStore;
|
use session::storage::RedisClusterSessionStore;
|
||||||
use session::SessionMiddleware;
|
use session::SessionMiddleware;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
|
|
||||||
@ -30,51 +30,35 @@ pub struct AppState {
|
|||||||
pub cache: AppCache,
|
pub cache: AppCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Skips Logger terminal output for noisy health/monitor/WS endpoints while still
|
/// Custom middleware that logs requests except for noisy paths.
|
||||||
/// passing the request through to the inner service (so metrics are still recorded).
|
struct RequestLogger;
|
||||||
struct SkipNoisyPaths<S>(S);
|
|
||||||
|
|
||||||
impl<S> SkipNoisyPaths<S> {
|
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for RequestLogger
|
||||||
fn new(logger: S) -> Self {
|
|
||||||
Self(logger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for SkipNoisyPaths<S>
|
|
||||||
where
|
where
|
||||||
S: actix_web::dev::Transform<
|
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||||
ServiceRequest,
|
|
||||||
Response = ServiceResponse<B>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
InitError = (),
|
|
||||||
>,
|
|
||||||
S::Future: 'static,
|
S::Future: 'static,
|
||||||
B: 'static,
|
B: 'static,
|
||||||
{
|
{
|
||||||
type Response = ServiceResponse<B>;
|
type Response = ServiceResponse<B>;
|
||||||
type Error = actix_web::Error;
|
type Error = actix_web::Error;
|
||||||
type Transform = SkipNoisyPathsService<S::Transform>;
|
type Transform = RequestLoggerService<S>;
|
||||||
type InitError = ();
|
type InitError = ();
|
||||||
|
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
|
||||||
|
|
||||||
fn new_transform(&self, service: S::Transform) -> <Self::Transform as actix_web::dev::Transform<
|
fn new_transform(&self, service: S) -> Self::Future {
|
||||||
ServiceRequest,
|
futures::future::ok(RequestLoggerService {
|
||||||
Response = ServiceResponse<B>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
InitError = (),
|
|
||||||
>>::Future {
|
|
||||||
futures::future::ok(SkipNoisyPathsService {
|
|
||||||
service,
|
service,
|
||||||
_marker: std::marker::PhantomData,
|
_marker: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SkipNoisyPathsService<S> {
|
struct RequestLoggerService<S> {
|
||||||
service: S,
|
service: S,
|
||||||
_marker: std::marker::PhantomData<fn(ServiceRequest)>,
|
_marker: std::marker::PhantomData<fn(ServiceRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, B> actix_web::dev::Service<ServiceRequest> for SkipNoisyPathsService<S>
|
impl<S, B> actix_web::dev::Service<ServiceRequest> for RequestLoggerService<S>
|
||||||
where
|
where
|
||||||
S: actix_web::dev::Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
S: actix_web::dev::Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||||
S::Future: 'static,
|
S::Future: 'static,
|
||||||
@ -90,19 +74,33 @@ where
|
|||||||
|
|
||||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||||
let path = req.path().to_string();
|
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 == "/metrics"
|
||||||
|| path.starts_with("/ws");
|
|| path.starts_with("/ws")
|
||||||
if should_skip {
|
|| path.starts_with("/assets"));
|
||||||
let fut = self.service.call(req);
|
|
||||||
Box::pin(async move {
|
let start = Instant::now();
|
||||||
let res = fut.await?;
|
let fut = self.service.call(req);
|
||||||
// Replace body so Logger doesn't print the line for this request.
|
|
||||||
Ok(res.map_body(|_, _| actix_web::body::Empty::new()))
|
Box::pin(async move {
|
||||||
})
|
let res = fut.await?;
|
||||||
} else {
|
if should_log {
|
||||||
self.service.call(req)
|
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()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(session_mw)
|
.wrap(session_mw)
|
||||||
.wrap(SkipNoisyPaths::new(Logger::default()))
|
.wrap(RequestLogger)
|
||||||
.wrap(metrics_mw)
|
.wrap(metrics_mw)
|
||||||
.wrap(TracingSpanMiddleware::new())
|
.wrap(TracingSpanMiddleware::new())
|
||||||
.app_data(web::Data::new(AppState {
|
.app_data(web::Data::new(AppState {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ actix-web = { workspace = true }
|
|||||||
actix-files = { workspace = true }
|
actix-files = { workspace = true }
|
||||||
actix-cors = { workspace = true }
|
actix-cors = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
mime = { workspace = true }
|
mime = { workspace = true }
|
||||||
@ -15,6 +16,7 @@ mime_guess2 = { workspace = true }
|
|||||||
slog = { workspace = true }
|
slog = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
env_logger = { workspace = true }
|
env_logger = { workspace = true }
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_files::Files;
|
use actix_files::Files;
|
||||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
|
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 futures::future::LocalBoxFuture;
|
||||||
|
use log::info;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Static file server for avatar, blob, and other static files
|
/// Static file server for avatar, blob, and other static files
|
||||||
/// Serves files from /data/{type} directories
|
/// Serves files from /data/{type} directories
|
||||||
@ -42,50 +44,35 @@ async fn health() -> HttpResponse {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Skips Logger terminal output for noisy health/WS endpoints.
|
/// Custom middleware that logs requests except for noisy paths (health, metrics, static files).
|
||||||
struct SkipNoisyPaths<S>(S);
|
struct RequestLogger;
|
||||||
|
|
||||||
impl<S> SkipNoisyPaths<S> {
|
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for RequestLogger
|
||||||
fn new(logger: S) -> Self {
|
|
||||||
Self(logger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, B> actix_web::dev::Transform<S, ServiceRequest> for SkipNoisyPaths<S>
|
|
||||||
where
|
where
|
||||||
S: actix_web::dev::Transform<
|
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||||
ServiceRequest,
|
|
||||||
Response = ServiceResponse<B>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
InitError = (),
|
|
||||||
>,
|
|
||||||
S::Future: 'static,
|
S::Future: 'static,
|
||||||
B: 'static,
|
B: 'static,
|
||||||
{
|
{
|
||||||
type Response = ServiceResponse<B>;
|
type Response = ServiceResponse<B>;
|
||||||
type Error = actix_web::Error;
|
type Error = actix_web::Error;
|
||||||
type Transform = SkipNoisyPathsService<S::Transform>;
|
type Transform = RequestLoggerService<S>;
|
||||||
type InitError = ();
|
type InitError = ();
|
||||||
|
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
|
||||||
|
|
||||||
fn new_transform(&self, service: S::Transform) -> <Self::Transform as actix_web::dev::Transform<
|
fn new_transform(&self, service: S) -> Self::Future {
|
||||||
ServiceRequest,
|
futures::future::ok(RequestLoggerService {
|
||||||
Response = ServiceResponse<B>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
InitError = (),
|
|
||||||
>>::Future {
|
|
||||||
futures::future::ok(SkipNoisyPathsService {
|
|
||||||
service,
|
service,
|
||||||
_marker: std::marker::PhantomData,
|
_marker: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SkipNoisyPathsService<S> {
|
struct RequestLoggerService<S> {
|
||||||
service: S,
|
service: S,
|
||||||
_marker: std::marker::PhantomData<fn(ServiceRequest)>,
|
_marker: std::marker::PhantomData<fn(ServiceRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, B> Service<ServiceRequest> for SkipNoisyPathsService<S>
|
impl<S, B> Service<ServiceRequest> for RequestLoggerService<S>
|
||||||
where
|
where
|
||||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||||
S::Future: 'static,
|
S::Future: 'static,
|
||||||
@ -101,17 +88,32 @@ where
|
|||||||
|
|
||||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||||
let path = req.path().to_string();
|
let path = req.path().to_string();
|
||||||
let should_skip =
|
let method = req.method().to_string();
|
||||||
path == "/health" || path == "/metrics" || path.starts_with("/ws");
|
let should_log = !(path == "/health"
|
||||||
if should_skip {
|
|| path == "/metrics"
|
||||||
let fut = self.service.call(req);
|
|| path.starts_with("/ws")
|
||||||
Box::pin(async move {
|
|| path.starts_with("/avatar")
|
||||||
let res = fut.await?;
|
|| path.starts_with("/blob")
|
||||||
Ok(res.map_body(|_, _| actix_web::body::Empty::new()))
|
|| path.starts_with("/media")
|
||||||
})
|
|| path.starts_with("/static"));
|
||||||
} else {
|
|
||||||
self.service.call(req)
|
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()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(SkipNoisyPaths::new(Logger::default()))
|
.wrap(RequestLogger)
|
||||||
.route("/health", web::get().to(health))
|
.route("/health", web::get().to(health))
|
||||||
.service(
|
.service(
|
||||||
Files::new("/avatar", root.join("avatar"))
|
Files::new("/avatar", root.join("avatar"))
|
||||||
|
|||||||
@ -7,6 +7,7 @@ pub mod issue;
|
|||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod pull_request;
|
pub mod pull_request;
|
||||||
|
pub mod robots;
|
||||||
pub mod room;
|
pub mod room;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|||||||
82
libs/api/project/avatar.rs
Normal file
82
libs/api/project/avatar.rs
Normal file
@ -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<AvatarUploadResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "No permission"),
|
||||||
|
),
|
||||||
|
tag = "Project"
|
||||||
|
)]
|
||||||
|
pub async fn upload_project_avatar(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
mut payload: Multipart,
|
||||||
|
) -> Result<HttpResponse, crate::error::ApiError> {
|
||||||
|
let project_name = path.into_inner();
|
||||||
|
let max_size: usize = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
let mut file_data: Vec<u8> = 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())
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
pub mod activity;
|
pub mod activity;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod avatar;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
pub mod board;
|
pub mod board;
|
||||||
pub mod info;
|
pub mod info;
|
||||||
@ -168,6 +169,10 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
"/{project_name}/activities",
|
"/{project_name}/activities",
|
||||||
web::post().to(activity::project_log_activity),
|
web::post().to(activity::project_log_activity),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{project_name}/avatar",
|
||||||
|
web::post().to(avatar::upload_project_avatar),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{project_name}/billing",
|
"/{project_name}/billing",
|
||||||
web::get().to(billing::project_billing),
|
web::get().to(billing::project_billing),
|
||||||
|
|||||||
0
libs/api/robots.rs
Normal file
0
libs/api/robots.rs
Normal file
@ -410,6 +410,7 @@ pub async fn ws_universal(
|
|||||||
username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()),
|
username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
action: action.to_string(),
|
action: action.to_string(),
|
||||||
|
sender_type: None,
|
||||||
};
|
};
|
||||||
manager.broadcast_typing(room_id, typing_event).await;
|
manager.broadcast_typing(room_id, typing_event).await;
|
||||||
}
|
}
|
||||||
|
|||||||
76
libs/api/user/avatar.rs
Normal file
76
libs/api/user/avatar.rs
Normal file
@ -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<AvatarUploadResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
tag = "User"
|
||||||
|
)]
|
||||||
|
pub async fn upload_avatar(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
mut payload: Multipart,
|
||||||
|
) -> Result<HttpResponse, crate::error::ApiError> {
|
||||||
|
let max_size: usize = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
let mut file_data: Vec<u8> = 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())
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod access_key;
|
pub mod access_key;
|
||||||
|
pub mod avatar;
|
||||||
pub mod chpc;
|
pub mod chpc;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
@ -18,6 +19,7 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
web::scope("/users")
|
web::scope("/users")
|
||||||
.route("/me/profile", web::get().to(profile::get_my_profile))
|
.route("/me/profile", web::get().to(profile::get_my_profile))
|
||||||
.route("/me/profile", web::patch().to(profile::update_my_profile))
|
.route("/me/profile", web::patch().to(profile::update_my_profile))
|
||||||
|
.route("/me/avatar", web::post().to(avatar::upload_avatar))
|
||||||
.route(
|
.route(
|
||||||
"/me/preferences",
|
"/me/preferences",
|
||||||
web::get().to(preferences::get_preferences),
|
web::get().to(preferences::get_preferences),
|
||||||
|
|||||||
@ -703,7 +703,7 @@ impl RoomConnectionManager {
|
|||||||
for key in keys {
|
for key in 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(Ok(value)), Some(Ok(user_uuid)))) = (
|
if let (Some(value), Some(user_uuid)) = (
|
||||||
redis::cmd("GET").arg(&key).query_async::<String>(&mut conn).await.ok(),
|
redis::cmd("GET").arg(&key).query_async::<String>(&mut conn).await.ok(),
|
||||||
user_id,
|
user_id,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ impl AppService {
|
|||||||
project_name: String,
|
project_name: String,
|
||||||
file: Vec<u8>,
|
file: Vec<u8>,
|
||||||
file_ext: String,
|
file_ext: String,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<String, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let project = self
|
let project = self
|
||||||
.utils_find_project_by_name(project_name.clone())
|
.utils_find_project_by_name(project_name.clone())
|
||||||
@ -26,14 +26,18 @@ impl AppService {
|
|||||||
if role == MemberRole::Member {
|
if role == MemberRole::Member {
|
||||||
return Err(AppError::NoPower);
|
return Err(AppError::NoPower);
|
||||||
}
|
}
|
||||||
let time = Utc::now();
|
let time = Utc::now().timestamp();
|
||||||
let file_name = format!("{}-{}", project.id, time);
|
let file_name = format!("{}-{}", project.id, time);
|
||||||
self.avatar
|
self.avatar
|
||||||
.upload(file, file_name.clone(), &file_ext)
|
.upload(file, file_name.clone(), &file_ext)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::AvatarUploadError(e.to_string()))?;
|
.map_err(|e| AppError::AvatarUploadError(e.to_string()))?;
|
||||||
let static_url = self.config.static_domain().unwrap_or("/static".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()
|
project::Entity::update_many()
|
||||||
.filter(project::Column::Id.eq(project.id))
|
.filter(project::Column::Id.eq(project.id))
|
||||||
.col_expr(project::Column::AvatarUrl, Expr::value(file_url.clone()))
|
.col_expr(project::Column::AvatarUrl, Expr::value(file_url.clone()))
|
||||||
@ -54,6 +58,6 @@ impl AppService {
|
|||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(file_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ impl AppService {
|
|||||||
.static_domain()
|
.static_domain()
|
||||||
.unwrap_or_else(|_| "/static".to_string());
|
.unwrap_or_else(|_| "/static".to_string());
|
||||||
let file_url = format!(
|
let file_url = format!(
|
||||||
"{}/{}",
|
"{}/avatar/{}",
|
||||||
static_url.trim_end_matches('/'),
|
static_url.trim_end_matches('/'),
|
||||||
format!("{}.{}", file_name, file_ext)
|
format!("{}.{}", file_name, file_ext)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Upload, User } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@ -30,12 +31,51 @@ export function SettingsGeneral() {
|
|||||||
const [displayName, setDisplayName] = useState(project?.display_name || '');
|
const [displayName, setDisplayName] = useState(project?.display_name || '');
|
||||||
const [description, setDescription] = useState(project?.description || '');
|
const [description, setDescription] = useState(project?.description || '');
|
||||||
const [isPublic, setIsPublic] = useState(project?.is_public ?? false);
|
const [isPublic, setIsPublic] = useState(project?.is_public ?? false);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(project?.avatar_url ?? '');
|
||||||
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
displayName !== (project?.display_name || '') ||
|
displayName !== (project?.display_name || '') ||
|
||||||
description !== (project?.description || '') ||
|
description !== (project?.description || '') ||
|
||||||
isPublic !== (project?.is_public ?? false);
|
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<HTMLInputElement>) => {
|
||||||
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!project?.name) return;
|
if (!project?.name) return;
|
||||||
|
|
||||||
@ -80,6 +120,47 @@ export function SettingsGeneral() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6 pt-2">
|
<CardContent className="space-y-6 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Project Avatar</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-16 w-16 rounded-lg border-2 border-muted bg-muted/50 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Project avatar"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
{uploadAvatarMutation.isPending && (
|
||||||
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => document.getElementById('project-avatar-upload')?.click()}
|
||||||
|
disabled={uploadAvatarMutation.isPending}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Upload avatar
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="project-avatar-upload"
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">PNG, JPG, GIF or WebP. Max 2MB.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display-name">Display Name</Label>
|
<Label htmlFor="display-name">Display Name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -43,45 +43,45 @@ export function SettingsProfile() {
|
|||||||
websiteUrl !== originalWebsiteUrl ||
|
websiteUrl !== originalWebsiteUrl ||
|
||||||
organization !== originalOrganization;
|
organization !== originalOrganization;
|
||||||
|
|
||||||
const uploadAvatarMutation = useMutation({
|
const uploadAvatarMutation = useMutation({
|
||||||
mutationFn: async (data: { image_data: string; format?: string }) => {
|
mutationFn: async (file: File) => {
|
||||||
// This would need an actual avatar upload API
|
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
// For now, we'll just use the data URL directly
|
const formData = new FormData();
|
||||||
return { data: { avatar_url: `data:image/png;base64,${data.image_data}` } };
|
formData.append('file', file);
|
||||||
},
|
const res = await fetch(`${baseUrl}/api/users/me/avatar`, {
|
||||||
onSuccess: (data: unknown) => {
|
method: 'POST',
|
||||||
toast.success('Avatar uploaded successfully');
|
body: formData,
|
||||||
const url = (data as { data?: { avatar_url?: string } })?.data?.avatar_url;
|
credentials: 'include',
|
||||||
if (url) setAvatarUrl(url);
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
|
if (!res.ok) {
|
||||||
},
|
const err = await res.json().catch(() => null);
|
||||||
onError: () => {
|
throw new Error(err?.message ?? 'Upload failed');
|
||||||
toast.error('Failed to upload avatar');
|
}
|
||||||
},
|
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<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
toast.error('File size must be less than 2MB');
|
toast.error('File size must be less than 2MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
uploadAvatarMutation.mutate(file);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user