194c44c
use sea_orm::{ActiveModelTrait, Set};
use time::OffsetDateTime;
use uuid::Uuid;
use models::agent::agent_item as agent;
use crate::errors::AgentError;
use crate::services::agents::AgentDto;
use crate::services::service::AgentsService;
use crate::share::authz::require_collection_manage;
use crate::share::lookup::agent_context;
pub const MAX_AGENT_AVATAR_BYTES: usize = 2 * 1024 * 1024;
const AGENT_AVATAR_PREFIX: &str = "agent-avatars";
const AGENT_AVATAR_URL_TTL_SECS: u32 = 6 * 60 * 60;
fn avatar_cache_key(agent_id: Uuid) -> String {
format!("agent:avatar:url:{agent_id}")
}
impl AgentsService {
/// Stores the raw avatar bytes in object storage and points the agent row at
/// the new object; returns the refreshed agent DTO.
#[tracing::instrument(
name = "agents.upload_agent_avatar",
skip_all,
fields(
caller = %caller,
space_name = %space_name,
collection_id = %collection_id,
agent_id = %agent_id
)
)]
pub async fn upload_agent_avatar(
&self,
caller: i64,
space_name: &str,
collection_id: Uuid,
agent_id: Uuid,
content_type: &str,
bytes: &[u8],
) -> Result<AgentDto, AgentError> {
let (space, _, row) =
agent_context(self, caller, space_name, collection_id, agent_id).await?;
require_collection_manage(self, &space, caller).await?;
if bytes.is_empty() {
return Err(AgentError::BadRequest("avatar image is empty".to_string()));
}
if bytes.len() > MAX_AGENT_AVATAR_BYTES {
return Err(AgentError::BadRequest(
"avatar image is larger than 2 MB".to_string(),
));
}
let clean = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
.to_lowercase();
let extension = match clean.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => {
return Err(AgentError::BadRequest(
"unsupported avatar image type".to_string(),
));
}
};
let key = format!("{AGENT_AVATAR_PREFIX}/{agent_id}.{extension}");
self.state
.storage
.client()
.put_with_content_type(&key, bytes, content_type)
.await
.map_err(|error| AgentError::Internal(error.to_string()))?;
let mut am: agent::ActiveModel = row.into();
am.avatar_url = Set(Some(format!("storage://{key}")));
am.updated_at = Set(Some(OffsetDateTime::now_utc()));
let updated = am.update(self.db().write()).await?;
if let Err(error) =
export::redis::delete(&self.state.redis, &avatar_cache_key(agent_id)).await
{
tracing::warn!(%error, "failed to drop cached agent avatar url");
}
let users = self.load_user_briefs(&[updated.author_id]).await?;
let author = users.get(&updated.author_id).cloned();
Ok(Self::agent_dto(updated, author, &space.lower_name))
}
/// Presigned URL for the agent avatar; cached so repeated avatar renders do
/// not re-sign on every request.
pub async fn agent_avatar_url(
&self,
caller: i64,
space_name: &str,
collection_id: Uuid,
agent_id: Uuid,
) -> Result<String, AgentError> {
let (_, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
let locator = row
.avatar_url
.filter(|url| !url.is_empty())
.ok_or(AgentError::NotFound)?;
let key = locator
.strip_prefix("storage://")
.ok_or(AgentError::NotFound)?;
let cache_key = avatar_cache_key(agent_id);
match export::redis::get_string(&self.state.redis, &cache_key).await {
Ok(Some(url)) => return Ok(url),
Ok(None) => {}
Err(error) => tracing::warn!(%error, "failed to read agent avatar url cache"),
}
let url = self
.state
.storage
.client()
.presign_get(key, AGENT_AVATAR_URL_TTL_SECS)
.map_err(|error| AgentError::Internal(error.to_string()))?;
if let Err(error) = export::redis::set_string(
&self.state.redis,
&cache_key,
&url,
AGENT_AVATAR_URL_TTL_SECS as u64,
)
.await
{
tracing::warn!(%error, "failed to cache agent avatar url");
}
Ok(url)
}
}