Skip to main content

backbone_bucket/application/service/
file_service.rs

1//! File upload service on top of [`ObjectStorage`].
2//!
3//! The plan calls for two upload surfaces:
4//!
5//! - [`FileService::upload`] — auto-generated UUID key (existing behavior).
6//! - [`FileService::upload_with_key`] — caller-controlled key, used when
7//!   the key is human-visible (e.g. mode-B public paths like
8//!   `public/product/image/slug.jpg`).
9//!
10//! Public-prefix routing is embedded here: keys beginning with
11//! `config.serving.public_prefix` route to the configured public bucket
12//! inside the [`ObjectStorage`] impl (if any), otherwise private.
13
14use 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/// Caller-supplied metadata for a new file.
27#[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    /// Optional logical owner attachment (consumer entity/module/id).
35    pub owner_module: Option<String>,
36    pub owner_entity: Option<String>,
37    pub owner_entity_id: Option<Uuid>,
38}
39
40/// File-operations service: writes bytes to [`ObjectStorage`] and the
41/// matching row to the `stored_files` table.
42pub 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    /// Upload with an auto-generated UUID storage key.
58    ///
59    /// The produced key has shape `{uuid}/{sanitized_name}` — stable
60    /// across renames, avoids accidental collisions, but not human
61    /// readable. Use [`Self::upload_with_key`] when the URL matters.
62    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    /// Upload under an explicit key.
76    ///
77    /// Keys beginning with `public_prefix` route to the public bucket
78    /// (handled inside the [`ObjectStorage`] impl). When the backend has
79    /// no public bucket configured, the key still writes to the private
80    /// bucket and a `debug` log event records the mismatch — dev
81    /// environments without a public bucket still work, but operators
82    /// can grep for the event before promoting a misconfiguration to
83    /// production.
84    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        // When the key is public-prefixed but no public URL exists, the
155        // upload still succeeds (the backend silently falls back to
156        // private). Surface a warning via the log, but don't fail —
157        // otherwise dev envs without a public bucket would reject public
158        // keys outright, which is overly rigid.
159        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    // Reject `..` path segments (traversal) while still allowing `..` to
177    // appear inside a filename like `report..v2.pdf`.
178    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}