systemprompt_ai/services/storage/
image_storage.rs1use 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#[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}