Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ async def login(data: UserLogin, background_tasks: BackgroundTasks, db: AsyncSes
tenant_id=u.tenant_id,
tenant_name=tenant.name if tenant else "Create or Join Organization",
tenant_slug=tenant.slug if tenant else "",
logo_url=tenant.logo_url if tenant else None,
))

return MultiTenantResponse(
Expand Down Expand Up @@ -759,7 +760,8 @@ async def get_my_tenants(
TenantChoice(
tenant_id=t.id,
tenant_name=t.name,
tenant_slug=t.slug
tenant_slug=t.slug,
logo_url=t.logo_url,
) for t in tenants
]

Expand Down
100 changes: 99 additions & 1 deletion backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
import re
import secrets
import uuid
import io
from datetime import datetime
from pathlib import Path

from fastapi import APIRouter, Depends, HTTPException, status
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel, Field
from sqlalchemy import func as sqla_func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import get_settings
from app.core.security import get_current_user, require_role, get_authenticated_user
from app.database import get_db
from app.models.tenant import Tenant
Expand All @@ -39,6 +45,7 @@ class TenantOut(BaseModel):
sso_enabled: bool = False
sso_domain: str | None = None
a2a_async_enabled: bool = False
logo_url: str | None = None
created_at: datetime | None = None

model_config = {"from_attributes": True}
Expand All @@ -55,6 +62,42 @@ class TenantUpdate(BaseModel):
a2a_async_enabled: bool | None = None


def _tenant_logo_dir() -> Path:
return Path(get_settings().AGENT_DATA_DIR) / "_tenant_logos"


def _tenant_logo_path(tenant_id: uuid.UUID) -> Path:
return _tenant_logo_dir() / f"{tenant_id}.png"


def _tenant_logo_url(tenant_id: uuid.UUID) -> str:
try:
mtime = int(_tenant_logo_path(tenant_id).stat().st_mtime)
except OSError:
mtime = int(datetime.utcnow().timestamp())
return f"/api/tenants/{tenant_id}/logo?v={mtime}"


async def _get_updateable_tenant(
tenant_id: uuid.UUID,
current_user: User,
db: AsyncSession,
) -> Tenant:
if current_user.role == "org_admin":
if not current_user.tenant_id:
raise HTTPException(status_code=403, detail="Organization admin must belong to a company")
if current_user.tenant_id != tenant_id:
raise HTTPException(status_code=403, detail="Can only update your own company")
elif current_user.role != "platform_admin":
raise HTTPException(status_code=403, detail="Admin access required")

result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant


# ─── Helpers ────────────────────────────────────────────

def _slugify(name: str) -> str:
Expand Down Expand Up @@ -476,6 +519,61 @@ async def update_tenant(
return TenantOut.model_validate(tenant)


@router.get("/{tenant_id}/logo")
async def get_tenant_logo(tenant_id: uuid.UUID):
"""Serve a tenant logo. Logos are public UI assets, addressed by UUID."""
path = _tenant_logo_path(tenant_id)
if not path.exists():
raise HTTPException(status_code=404, detail="Logo not found")
return FileResponse(path, media_type="image/png")


@router.post("/{tenant_id}/logo", response_model=TenantOut)
async def upload_tenant_logo(
tenant_id: uuid.UUID,
file: UploadFile = File(...),
current_user: User = Depends(require_role("org_admin", "platform_admin")),
db: AsyncSession = Depends(get_db),
):
"""Upload a cropped square company logo.

The frontend crops to a 1:1 PNG before upload. The backend keeps a hard
1 MB limit and stores the image outside git-managed source files.
"""
tenant = await _get_updateable_tenant(tenant_id, current_user, db)
if file.content_type not in {"image/png", "image/jpeg", "image/webp"}:
raise HTTPException(status_code=400, detail="Logo must be a PNG, JPEG, or WebP image")

data = await file.read()
if len(data) > 1024 * 1024:
raise HTTPException(status_code=400, detail="Logo image must be 1 MB or smaller")
try:
image = Image.open(io.BytesIO(data))
Comment on lines +547 to +551
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject oversized logo dimensions before decoding

The upload handler enforces only encoded file size (<=1 MB) but then fully decodes and converts the image without any pixel-dimension cap. Highly compressed large PNG/WebP files can stay under 1 MB while expanding to very large in-memory buffers during image.load()/convert("RGBA"), which can stall or OOM API workers. Add a maximum width/height (or total pixel) check before expensive processing.

Useful? React with 👍 / 👎.

image.load()
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid image file") from exc
if image.width != image.height:
raise HTTPException(status_code=400, detail="Logo image must be a 1:1 square")

output = io.BytesIO()
image.convert("RGBA").save(output, format="PNG", optimize=True)
png_data = output.getvalue()
if len(png_data) > 1024 * 1024:
raise HTTPException(status_code=400, detail="Logo image must be 1 MB or smaller after processing")

logo_dir = _tenant_logo_dir()
logo_dir.mkdir(parents=True, exist_ok=True)
path = _tenant_logo_path(tenant_id)
async with aiofiles.open(path, "wb") as f:
await f.write(png_data)

config = dict(tenant.im_config or {})
config["logo_url"] = _tenant_logo_url(tenant_id)
tenant.im_config = config
await db.flush()
return TenantOut.model_validate(tenant)


@router.put("/{tenant_id}/assign-user/{user_id}")
async def assign_user_to_tenant(
tenant_id: uuid.UUID,
Expand Down
8 changes: 8 additions & 0 deletions backend/app/models/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ class Tenant(Base):
# A2A async communication (notify / task_delegate)
# When False, all agent-to-agent messages use synchronous consult mode
a2a_async_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

@property
def logo_url(self) -> str | None:
"""Tenant logo URL stored in flexible tenant config."""
if isinstance(self.im_config, dict):
value = self.im_config.get("logo_url")
return value if isinstance(value, str) and value else None
return None
1 change: 1 addition & 0 deletions backend/app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class TenantChoice(BaseModel):
tenant_id: uuid.UUID | None
tenant_name: str
tenant_slug: str
logo_url: str | None = None


class MultiTenantResponse(BaseModel):
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,18 @@
"title": "Company Name",
"placeholder": "Enter company name"
},
"logo": {
"title": "Company Logo",
"description": "Used in the sidebar workspace switcher and company selection menus.",
"upload": "Upload logo",
"hint": "PNG, JPG, or WebP. Max 1 MB. You will crop it to a square before saving.",
"cropTitle": "Crop company logo",
"zoom": "Zoom",
"tooLarge": "Logo image must be 1 MB or smaller.",
"invalidType": "Please choose an image file.",
"croppedTooLarge": "Cropped logo is still larger than 1 MB.",
"uploadFailed": "Failed to upload logo."
},
"companyIntro": {
"title": "Company Intro",
"description": "Describe your company's mission, products, and culture. This information will be included as context in every Agent conversation.",
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,18 @@
"title": "公司名称",
"placeholder": "输入公司名称"
},
"logo": {
"title": "公司标识",
"description": "用于左侧公司切换入口和公司选择菜单。",
"upload": "上传标识",
"hint": "支持 PNG、JPG 或 WebP,最大 1MB。保存前需要裁剪为正方形。",
"cropTitle": "裁剪公司标识",
"zoom": "缩放",
"tooLarge": "Logo 图片不能大于 1MB。",
"invalidType": "请选择图片文件。",
"croppedTooLarge": "裁剪后的 Logo 仍然大于 1MB。",
"uploadFailed": "Logo 上传失败。"
},
"companyIntro": {
"title": "公司简介",
"description": "描述你的公司使命、产品和文化。此信息会作为上下文包含在每次 Agent 对话中。",
Expand Down
Loading