gitdataai/lib/channel/cdn.rs
2026-05-30 01:38:40 +08:00

137 lines
3.5 KiB
Rust

use std::time::Duration;
use uuid::Uuid;
use storage::ObjectStorage;
use crate::{ChannelError, ChannelResult};
const ATTACHMENT_KEY_PREFIX: &str = "attachments";
const DEFAULT_PRESIGNED_TTL: Duration = Duration::from_secs(3600);
const DEFAULT_MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
#[derive(Clone)]
pub struct CdnManager {
storage: storage::AppStorage,
presigned_ttl: Duration,
max_file_size: u64,
}
impl CdnManager {
pub fn new(storage: storage::AppStorage) -> Self {
Self {
storage,
presigned_ttl: DEFAULT_PRESIGNED_TTL,
max_file_size: DEFAULT_MAX_FILE_SIZE,
}
}
pub fn with_config(
storage: storage::AppStorage,
presigned_ttl: Duration,
max_file_size: u64,
) -> Self {
Self {
storage,
presigned_ttl,
max_file_size,
}
}
pub fn max_file_size(&self) -> u64 {
self.max_file_size
}
pub async fn upload_file(
&self,
room_id: Uuid,
message_id: Uuid,
file_data: &[u8],
filename: &str,
content_type: Option<String>,
) -> ChannelResult<CdnStoredFile> {
if file_data.len() as u64 > self.max_file_size {
return Err(ChannelError::Internal(
"file exceeds max size".to_string(),
));
}
let filename = sanitize_filename(filename);
let key = format!(
"{}/{}/{}/{}",
ATTACHMENT_KEY_PREFIX, room_id, message_id, filename
);
let options = storage::PutObjectOptions {
content_type,
content_length: Some(file_data.len() as i64),
cache_control: Some("public, max-age=86400".to_string()),
};
let stored = self
.storage
.put_bytes(&key, file_data.to_vec(), options)
.await?;
Ok(CdnStoredFile {
key: stored.key,
url: stored.url,
e_tag: stored.e_tag,
size: file_data.len() as i64,
})
}
pub async fn get_file(&self, key: &str) -> ChannelResult<CdnFileContent> {
let object = self.storage.get_bytes(key).await?;
Ok(CdnFileContent {
bytes: object.bytes,
content_type: object.content_type,
content_length: object.content_length,
})
}
pub async fn delete_file(&self, key: &str) -> ChannelResult<()> {
self.storage.delete(key).await?;
Ok(())
}
pub fn public_url(&self, key: &str) -> ChannelResult<Option<String>> {
self.storage.public_url(key).map_err(Into::into)
}
pub async fn presigned_url(&self, key: &str) -> ChannelResult<String> {
Ok(self
.storage
.presigned_get_url(key, self.presigned_ttl)
.await?)
}
}
fn sanitize_filename(name: &str) -> String {
let cleaned: String = name
.chars()
.filter(|&c| c.is_ascii_graphic() && c != '/' && c != '\\' && c != '\0')
.take(255)
.collect::<String>()
.trim()
.to_owned();
if cleaned.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
cleaned
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CdnStoredFile {
pub key: String,
pub url: String,
pub e_tag: Option<String>,
pub size: i64,
}
#[derive(Debug)]
pub struct CdnFileContent {
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_length: Option<i64>,
}