Files
openccb/services/cms-service/src/handlers_branding.rs
T

356 lines
11 KiB
Rust

// Organization Branding Handlers
use axum::{
Json,
extract::State,
http::StatusCode,
};
use common::models::Organization;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use super::handlers::{log_action, Org};
#[derive(Deserialize, Serialize)]
pub struct BrandingPayload {
pub name: Option<String>,
pub primary_color: Option<String>,
pub secondary_color: Option<String>,
pub platform_name: Option<String>,
pub logo_variant: Option<String>,
}
#[derive(Serialize)]
pub struct BrandingResponse {
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub platform_name: Option<String>,
pub logo_variant: Option<String>,
pub primary_color: String,
pub secondary_color: String,
}
// Upload organization logo
pub async fn upload_organization_logo(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Only admins can upload logos
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
}
// Verify organization exists and user has access
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
// Process multipart form
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
{
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let filename = field
.file_name()
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
.to_string();
// Validate file extension
let ext = filename.split('.').last().unwrap_or("");
if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid file type. Only PNG, JPG, and SVG allowed".into(),
));
}
let data = field.bytes().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read file: {}", e),
)
})?;
// Validate file size (max 2MB)
if data.len() > 2 * 1024 * 1024 {
return Err((
StatusCode::BAD_REQUEST,
"File too large. Maximum 2MB allowed".into(),
));
}
// Create uploads directory if it doesn't exist
std::fs::create_dir_all("uploads/org-logos").map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {}", e),
)
})?;
// Generate unique filename
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
let filepath = format!("uploads/org-logos/{}", unique_filename);
// Save file
std::fs::write(&filepath, &data).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save file: {}", e),
)
})?;
// Update organization in database
let logo_url = format!("/{}", filepath);
sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2")
.bind(&logo_url)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
})?;
log_action(
&pool,
claims.org,
claims.sub,
"UPDATE_LOGO",
"Organization",
org_ctx.id,
json!({"logo_url": &logo_url}),
)
.await;
return Ok(Json(json!({
"logo_url": logo_url,
"message": "Logo uploaded successfully"
})));
}
}
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
}
// Upload organization favicon
pub async fn upload_organization_favicon(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Only admins can upload favicons
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
}
// Verify organization exists and user has access
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
// Process multipart form
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
{
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let filename = field
.file_name()
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
.to_string();
// Validate file extension
let ext = filename.split('.').last().unwrap_or("");
if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(),
));
}
let data = field.bytes().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read file: {}", e),
)
})?;
// Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe)
if data.len() > 1024 * 1024 {
return Err((
StatusCode::BAD_REQUEST,
"File too large. Maximum 1MB allowed".into(),
));
}
// Create uploads directory if it doesn't exist
std::fs::create_dir_all("uploads/org-favicons").map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {}", e),
)
})?;
// Generate unique filename
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
let filepath = format!("uploads/org-favicons/{}", unique_filename);
// Save file
std::fs::write(&filepath, &data).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save file: {}", e),
)
})?;
// Update organization in database
let favicon_url = format!("/{}", filepath);
sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2")
.bind(&favicon_url)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
})?;
log_action(
&pool,
claims.org,
claims.sub,
"UPDATE_FAVICON",
"Organization",
org_ctx.id,
json!({"favicon_url": &favicon_url}),
)
.await;
return Ok(Json(json!({
"favicon_url": favicon_url,
"message": "Favicon uploaded successfully"
})));
}
}
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
}
// Update organization branding colors
pub async fn update_organization_branding(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Json(payload): Json<BrandingPayload>,
) -> Result<Json<Organization>, (StatusCode, String)> {
// Only admins can update branding
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
}
// Validate hex color format
let validate_color = |color: &str| -> bool {
color.len() == 7
&& color.starts_with('#')
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
};
if let Some(ref primary) = payload.primary_color {
if !validate_color(primary) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid primary_color format. Use #RRGGBB".into(),
));
}
}
if let Some(ref secondary) = payload.secondary_color {
if !validate_color(secondary) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid secondary_color format. Use #RRGGBB".into(),
));
}
}
// Update organization
let org = sqlx::query_as::<_, Organization>(
"UPDATE organizations
SET name = COALESCE($1, name),
primary_color = COALESCE($2, primary_color),
secondary_color = COALESCE($3, secondary_color),
platform_name = COALESCE($4, platform_name),
logo_variant = COALESCE($5, logo_variant),
updated_at = NOW()
WHERE id = $6
RETURNING *",
)
.bind(&payload.name)
.bind(&payload.primary_color)
.bind(&payload.secondary_color)
.bind(&payload.platform_name)
.bind(&payload.logo_variant)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)
})?;
log_action(
&pool,
claims.org,
claims.sub,
"UPDATE_BRANDING",
"Organization",
org_ctx.id,
json!(payload),
)
.await;
Ok(Json(org))
}
// Get organization branding (public endpoint)
pub async fn get_organization_branding(
State(pool): State<PgPool>,
) -> Result<Json<BrandingResponse>, StatusCode> {
let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap())
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(BrandingResponse {
logo_url: org.logo_url,
favicon_url: org.favicon_url,
platform_name: org.platform_name,
logo_variant: org.logo_variant,
primary_color: org.primary_color.unwrap_or_else(|| "#3B82F6".to_string()),
secondary_color: org.secondary_color.unwrap_or_else(|| "#8B5CF6".to_string()),
}))
}