Skip to main content

systemprompt_ai/services/storage/
image_storage.rs

1//! Storage for generated images behind the [`FileStorage`] backend.
2//!
3//! [`ImageStorage`] persists decoded image bytes under a configured base path
4//! relative to the storage root, optionally sharding by capture date, and
5//! returns both the storage id and the public URL. [`StorageConfig`] holds
6//! the base path, URL prefix, size cap, and date-organisation flag and
7//! validates them before any write.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use crate::error::AiError;
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD as BASE64;
15use chrono::{Datelike, Utc};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use std::sync::Arc;
19use systemprompt_traits::{FileStorage, ImageStorageConfig, StoredFileId};
20use uuid::Uuid;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct StorageConfig {
24    pub base_path: PathBuf,
25    pub url_prefix: String,
26    #[serde(default = "default_max_file_size")]
27    pub max_file_size_bytes: usize,
28    #[serde(default = "default_organize_by_date")]
29    pub organize_by_date: bool,
30}
31
32const fn default_max_file_size() -> usize {
33    10 * 1024 * 1024
34}
35
36const fn default_organize_by_date() -> bool {
37    true
38}
39
40impl StorageConfig {
41    pub fn from_image_storage_config(config: ImageStorageConfig) -> Self {
42        Self {
43            base_path: config.base_path,
44            url_prefix: config.url_prefix,
45            max_file_size_bytes: default_max_file_size(),
46            organize_by_date: true,
47        }
48    }
49}
50
51impl StorageConfig {
52    pub const fn new(base_path: PathBuf, url_prefix: String) -> Self {
53        Self {
54            base_path,
55            url_prefix,
56            max_file_size_bytes: default_max_file_size(),
57            organize_by_date: default_organize_by_date(),
58        }
59    }
60
61    pub const fn validate(&self) -> Result<(), StorageConfigError> {
62        if self.url_prefix.is_empty() {
63            return Err(StorageConfigError::EmptyUrlPrefix);
64        }
65
66        if self.max_file_size_bytes == 0 {
67            return Err(StorageConfigError::ZeroMaxFileSize);
68        }
69
70        Ok(())
71    }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
75pub enum StorageConfigError {
76    #[error("url_prefix cannot be empty")]
77    EmptyUrlPrefix,
78
79    #[error("max_file_size_bytes must be greater than 0")]
80    ZeroMaxFileSize,
81}
82
83/// A decoded image written to storage: its key, public URL and the size of
84/// the bytes actually stored (not the base64 transport length).
85#[derive(Debug, Clone)]
86pub struct StoredImage {
87    pub id: StoredFileId,
88    pub public_url: String,
89    pub size_bytes: usize,
90}
91
92pub struct ImageStorage {
93    config: StorageConfig,
94    storage: Arc<dyn FileStorage>,
95}
96
97impl std::fmt::Debug for ImageStorage {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("ImageStorage")
100            .field("config", &self.config)
101            .finish_non_exhaustive()
102    }
103}
104
105impl ImageStorage {
106    pub fn new(config: StorageConfig, storage: Arc<dyn FileStorage>) -> Result<Self, AiError> {
107        config.validate()?;
108
109        Ok(Self { config, storage })
110    }
111
112    pub async fn save_base64_image(
113        &self,
114        base64_data: &str,
115        mime_type: &str,
116    ) -> Result<StoredImage, AiError> {
117        let image_bytes = BASE64.decode(base64_data)?;
118
119        let (id, public_url) = self.save_image_bytes(&image_bytes, mime_type).await?;
120        Ok(StoredImage {
121            id,
122            public_url,
123            size_bytes: image_bytes.len(),
124        })
125    }
126
127    pub async fn save_image_bytes(
128        &self,
129        image_bytes: &[u8],
130        mime_type: &str,
131    ) -> Result<(StoredFileId, String), AiError> {
132        if image_bytes.len() > self.config.max_file_size_bytes {
133            return Err(AiError::ImageTooLarge {
134                size: image_bytes.len(),
135                max: self.config.max_file_size_bytes,
136            });
137        }
138
139        let extension = Self::mime_type_to_extension(mime_type);
140        let filename = format!(
141            "{}_{}.{}",
142            Uuid::new_v4(),
143            Utc::now().timestamp(),
144            extension
145        );
146
147        let partition = self.date_partition();
148        let relative_path = self.config.base_path.join(&partition).join(&filename);
149        let url_path = format!(
150            "{}/{}{}",
151            self.config.url_prefix,
152            if partition.is_empty() {
153                String::new()
154            } else {
155                format!("{partition}/")
156            },
157            filename
158        );
159
160        let stored_id = self
161            .storage
162            .store(&relative_path, image_bytes)
163            .await
164            .map_err(|source| AiError::Storage {
165                context: format!("failed to write image file {}", relative_path.display()),
166                source,
167            })?;
168
169        Ok((stored_id, url_path))
170    }
171
172    pub async fn delete_image(&self, id: &StoredFileId) -> Result<(), AiError> {
173        self.storage
174            .delete(id)
175            .await
176            .map_err(|source| AiError::Storage {
177                context: format!("failed to delete file {id}"),
178                source,
179            })
180    }
181
182    pub async fn exists(&self, id: &StoredFileId) -> Result<bool, AiError> {
183        self.storage
184            .exists(id)
185            .await
186            .map_err(|source| AiError::Storage {
187                context: format!("failed to stat file {id}"),
188                source,
189            })
190    }
191
192    pub fn get_full_path(&self, relative_path: &str) -> PathBuf {
193        self.config.base_path.join(relative_path)
194    }
195
196    fn date_partition(&self) -> String {
197        if !self.config.organize_by_date {
198            return String::new();
199        }
200        let now = Utc::now();
201        format!("{:04}/{:02}/{:02}", now.year(), now.month(), now.day())
202    }
203
204    fn mime_type_to_extension(mime_type: &str) -> String {
205        match mime_type {
206            "image/jpeg" | "image/jpg" => "jpg",
207            "image/webp" => "webp",
208            "image/gif" => "gif",
209            _ => "png",
210        }
211        .to_owned()
212    }
213}