backbone_bucket/application/service/
file_service.rs1use std::sync::Arc;
15
16use bytes::Bytes;
17use chrono::Utc;
18use uuid::Uuid;
19
20use crate::config::BucketConfig;
21use crate::domain::entity::{AuditMetadata, FileStatus, StoredFile};
22use crate::error::{BucketError, BucketResult};
23use crate::infrastructure::persistence::StoredFileRepository;
24use crate::storage::ObjectStorage;
25
26#[derive(Debug, Clone)]
28pub struct FileMeta {
29 pub bucket_id: Uuid,
30 pub owner_id: Uuid,
31 pub original_name: String,
32 pub mime_type: String,
33 pub path: String,
34 pub owner_module: Option<String>,
36 pub owner_entity: Option<String>,
37 pub owner_entity_id: Option<Uuid>,
38}
39
40pub struct FileService {
43 storage: Arc<dyn ObjectStorage>,
44 files: Arc<StoredFileRepository>,
45 config: Arc<BucketConfig>,
46}
47
48impl FileService {
49 pub fn new(
50 storage: Arc<dyn ObjectStorage>,
51 files: Arc<StoredFileRepository>,
52 config: Arc<BucketConfig>,
53 ) -> Self {
54 Self { storage, files, config }
55 }
56
57 pub async fn upload(
63 &self,
64 body: Bytes,
65 meta: FileMeta,
66 ) -> BucketResult<StoredFile> {
67 let key = format!(
68 "{}/{}",
69 Uuid::new_v4(),
70 sanitize_filename(&meta.original_name)
71 );
72 self.upload_with_key(&key, body, meta).await
73 }
74
75 pub async fn upload_with_key(
85 &self,
86 key: &str,
87 body: Bytes,
88 meta: FileMeta,
89 ) -> BucketResult<StoredFile> {
90 validate_key(key)?;
91 self.check_public_routing(key)?;
92
93 let size = body.len() as i64;
94 self.storage.put(key, body, &meta.mime_type).await?;
95
96 let now = Utc::now();
97 let file = StoredFile {
98 id: Uuid::new_v4(),
99 bucket_id: meta.bucket_id,
100 owner_id: meta.owner_id,
101 path: meta.path,
102 original_name: meta.original_name,
103 size_bytes: size,
104 mime_type: meta.mime_type,
105 checksum: None,
106 is_compressed: false,
107 original_size: None,
108 compression_algorithm: None,
109 is_scanned: false,
110 scan_result: None,
111 threat_level: None,
112 has_thumbnail: false,
113 thumbnail_path: None,
114 has_video_thumbnail: false,
115 has_document_preview: false,
116 processing_status: None,
117 content_hash_id: None,
118 cdn_url: None,
119 cdn_url_expires_at: None,
120 owner_module: meta.owner_module,
121 owner_entity: meta.owner_entity,
122 owner_entity_id: meta.owner_entity_id,
123 field_name: None,
124 sort_order: 0,
125 storage_key: key.to_string(),
126 version: 1,
127 previous_version_id: None,
128 download_count: 0,
129 last_accessed_at: None,
130 status: FileStatus::Active,
131 metadata: AuditMetadata {
132 created_at: Some(now),
133 updated_at: Some(now),
134 deleted_at: None,
135 created_by: Some(meta.owner_id),
136 updated_by: Some(meta.owner_id),
137 deleted_by: None,
138 },
139 };
140
141 self.files
142 .create(&file)
143 .await
144 .map_err(|e| BucketError::Other(format!("persist stored_file: {e}")))?;
145
146 Ok(file)
147 }
148
149 fn check_public_routing(&self, key: &str) -> BucketResult<()> {
150 let prefix = &self.config.serving.public_prefix;
151 if prefix.is_empty() || !key.starts_with(prefix.as_str()) {
152 return Ok(());
153 }
154 if self.storage.public_url(key).is_none() {
160 tracing::debug!(
161 key,
162 "public-prefixed key uploaded to backend without public bucket configured"
163 );
164 }
165 Ok(())
166 }
167}
168
169fn validate_key(key: &str) -> BucketResult<()> {
170 if key.is_empty() {
171 return Err(BucketError::Other("empty key".into()));
172 }
173 if key.starts_with('/') {
174 return Err(BucketError::Other(format!("invalid key: {key}")));
175 }
176 if key.split('/').any(|seg| seg == "..") {
179 return Err(BucketError::Other(format!("invalid key: {key}")));
180 }
181 Ok(())
182}
183
184fn sanitize_filename(name: &str) -> String {
185 let s: String = name
186 .chars()
187 .map(|c| match c {
188 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
189 c if c.is_ascii_control() => '_',
190 c => c,
191 })
192 .collect();
193 if s.is_empty() { "file".to_string() } else { s }
194}