use export::redis; use models::admin::admin_user::AdminRole; use serde::{Deserialize, Serialize}; use serde_json::json; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use crate::errors::AdminError; use crate::services::audit::{AuditCtx, AuditRecord}; use crate::services::service::AdminService; use crate::share::roles::AdminActor; const OVERVIEW_CACHE_KEY: &str = "admin:storage:overview"; const OVERVIEW_CACHE_TTL_SECS: u64 = 60; const MAX_PRESIGN_SECS: u32 = 3600; const CATEGORY_PREFIXES: [(&str, &str); 6] = [ ("user_avatars", "avatars/"), ("space_avatars", "space-avatars/"), ("space_images", "space-images/"), ("chat", "chat/"), ("lfs", "lfs/"), ("git", "git/"), ]; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct StorageCategoryDto { pub name: String, pub prefix: String, pub bytes: u64, pub objects: u64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct StorageOverviewDto { pub bucket: String, pub total_bytes: u64, pub total_objects: u64, pub categories: Vec, pub generated_at: OffsetDateTime, pub cached: bool, } #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct ListObjectsQuery { pub prefix: Option, pub token: Option, pub limit: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct StorageObjectDto { pub key: String, pub size: u64, pub last_modified: Option, pub etag: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct StorageObjectPageDto { pub prefix: String, pub items: Vec, pub next_token: Option, } #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct ObjectKeyQuery { pub key: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DownloadUrlDto { pub key: String, pub url: String, pub expires_in_secs: u32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DeleteObjectResp { pub key: String, pub deleted: bool, } impl AdminService { pub async fn storage_overview(&self, refresh: bool) -> Result { if !refresh && let Ok(Some(raw)) = redis::get_string(self.redis(), OVERVIEW_CACHE_KEY).await && let Ok(mut cached) = serde_json::from_str::(&raw) { cached.cached = true; return Ok(cached); } let client = self.storage().client(); let objects = client.list("").await?; let mut total_bytes: u64 = 0; let mut total_objects: u64 = 0; let mut categories: Vec = CATEGORY_PREFIXES .iter() .map(|(name, prefix)| StorageCategoryDto { name: name.to_string(), prefix: prefix.to_string(), bytes: 0, objects: 0, }) .collect(); let mut other = StorageCategoryDto { name: "other".to_string(), prefix: String::new(), bytes: 0, objects: 0, }; for object in &objects { total_bytes += object.size; total_objects += 1; match categories .iter_mut() .find(|category| object.key.starts_with(&category.prefix)) { Some(category) => { category.bytes += object.size; category.objects += 1; } None => { other.bytes += object.size; other.objects += 1; } } } categories.push(other); let overview = StorageOverviewDto { bucket: client.bucket_name().to_string(), total_bytes, total_objects, categories, generated_at: OffsetDateTime::now_utc(), cached: false, }; if let Ok(serialized) = serde_json::to_string(&overview) { let _ = redis::set_string( self.redis(), OVERVIEW_CACHE_KEY, &serialized, OVERVIEW_CACHE_TTL_SECS, ) .await; } Ok(overview) } pub async fn list_storage_objects( &self, query: ListObjectsQuery, ) -> Result { let prefix = query.prefix.unwrap_or_default(); let limit = query.limit.unwrap_or(100).clamp(1, 1000); let page = self .storage() .client() .list_page(&prefix, query.token.as_deref(), Some(limit)) .await?; let items = page .objects .into_iter() .map(|object| StorageObjectDto { key: object.key, size: object.size, last_modified: object .last_modified .as_ref() .and_then(|value| serde_json::to_value(value).ok()), etag: object.etag, }) .collect(); Ok(StorageObjectPageDto { prefix, items, next_token: page.next_continuation_token, }) } pub async fn storage_download_url(&self, key: String) -> Result { if key.trim().is_empty() { return Err(AdminError::BadRequest("key must not be empty".to_string())); } let exists = self.storage().client().exists(&key).await?; if !exists { return Err(AdminError::NotFound); } let url = self .storage() .client() .presign_get(&key, MAX_PRESIGN_SECS)?; Ok(DownloadUrlDto { key, url, expires_in_secs: MAX_PRESIGN_SECS, }) } pub async fn delete_storage_object( &self, actor: &AdminActor, key: String, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::SuperAdmin)?; if key.trim().is_empty() { return Err(AdminError::BadRequest("key must not be empty".to_string())); } let exists = self.storage().client().exists(&key).await?; if !exists { return Err(AdminError::NotFound); } self.storage().client().delete(&key).await?; self.audit( Some(actor), AuditRecord { action: "storage.object_deleted", target: Some(("storage_object", key.clone())), detail: json!({ "bucket": self.storage().config().bucket }), }, ctx, ) .await?; Ok(DeleteObjectResp { key, deleted: true }) } }