torrust_index/cache/image/
manager.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, SystemTime};
4
5use bytes::Bytes;
6use tokio::sync::RwLock;
7
8use crate::cache::BytesCache;
9use crate::config::Configuration;
10use crate::models::user::UserId;
11
12pub enum Error {
13    UrlIsUnreachable,
14    UrlIsNotAnImage,
15    ImageTooBig,
16    UserQuotaMet,
17    Unauthenticated,
18}
19
20type UserQuotas = HashMap<i64, ImageCacheQuota>;
21
22/// Returns the current time in seconds.
23///
24/// # Panics
25///
26/// This function will panic if the current time is before the UNIX EPOCH.
27#[must_use]
28pub fn now_in_secs() -> u64 {
29    SystemTime::now()
30        .duration_since(SystemTime::UNIX_EPOCH)
31        .expect("SystemTime before UNIX EPOCH!")
32        .as_secs()
33}
34
35#[derive(Clone)]
36pub struct ImageCacheQuota {
37    pub user_id: i64,
38    pub usage: usize,
39    pub max_usage: usize,
40    pub date_start_secs: u64,
41    pub period_secs: u64,
42}
43
44impl ImageCacheQuota {
45    #[must_use]
46    pub fn new(user_id: i64, max_usage: usize, period_secs: u64) -> Self {
47        Self {
48            user_id,
49            usage: 0,
50            max_usage,
51            date_start_secs: now_in_secs(),
52            period_secs,
53        }
54    }
55
56    /// Add Usage Quota
57    ///
58    /// # Errors
59    ///
60    /// This function will return a `Error::UserQuotaMet` if user quota has been met.
61    pub fn add_usage(&mut self, amount: usize) -> Result<(), Error> {
62        // Check if quota needs to be reset.
63        if now_in_secs() - self.date_start_secs > self.period_secs {
64            self.reset();
65        }
66
67        if self.is_reached() {
68            return Err(Error::UserQuotaMet);
69        }
70
71        self.usage = self.usage.saturating_add(amount);
72
73        Ok(())
74    }
75
76    pub fn reset(&mut self) {
77        self.usage = 0;
78        self.date_start_secs = now_in_secs();
79    }
80
81    #[must_use]
82    pub fn is_reached(&self) -> bool {
83        self.usage >= self.max_usage
84    }
85}
86
87pub struct ImageCacheService {
88    image_cache: RwLock<BytesCache>,
89    user_quotas: RwLock<UserQuotas>,
90    reqwest_client: reqwest::Client,
91    cfg: Arc<Configuration>,
92}
93
94impl ImageCacheService {
95    /// Create a new image cache service.
96    ///
97    /// # Panics
98    ///
99    /// This function will panic if the image cache could not be created.
100    pub async fn new(cfg: Arc<Configuration>) -> Self {
101        let settings = cfg.settings.read().await;
102
103        let image_cache =
104            BytesCache::with_capacity_and_entry_size_limit(settings.image_cache.capacity, settings.image_cache.entry_size_limit)
105                .expect("Could not create image cache.");
106
107        let reqwest_client = reqwest::Client::builder()
108            .timeout(Duration::from_millis(settings.image_cache.max_request_timeout_ms))
109            .build()
110            .expect("unable to build client request");
111
112        drop(settings);
113
114        Self {
115            image_cache: RwLock::new(image_cache),
116            user_quotas: RwLock::new(HashMap::new()),
117            reqwest_client,
118            cfg,
119        }
120    }
121
122    /// Get an image from the url and insert it into the cache if it isn't cached already.
123    /// Unauthenticated users can only get already cached images.
124    ///
125    /// # Errors
126    ///
127    /// Return a `Error::Unauthenticated` if the user has not been authenticated.
128    pub async fn get_image_by_url(&self, url: &str, user_id: UserId) -> Result<Bytes, Error> {
129        if let Some(entry) = self.image_cache.read().await.get(url).await {
130            return Ok(entry.bytes);
131        }
132        self.check_user_quota(&user_id).await?;
133
134        let image_bytes = self.get_image_from_url_as_bytes(url).await?;
135
136        self.check_image_size(&image_bytes).await?;
137
138        // These two functions could be executed after returning the image to the client,
139        // but than we would need a dedicated task or thread that executes these functions.
140        // This can be problematic if a task is spawned after every user request.
141        // Since these functions execute very fast, I don't see a reason to further optimize this.
142        // For now.
143        self.update_image_cache(url, &image_bytes).await?;
144
145        self.update_user_quota(&user_id, image_bytes.len()).await?;
146
147        Ok(image_bytes)
148    }
149
150    async fn get_image_from_url_as_bytes(&self, url: &str) -> Result<Bytes, Error> {
151        let res = self
152            .reqwest_client
153            .clone()
154            .get(url)
155            .send()
156            .await
157            .map_err(|_| Error::UrlIsUnreachable)?;
158
159        // code-review: we could get a HTTP 304 response, which doesn't contain a body (the image bytes).
160
161        if let Some(content_type) = res.headers().get("Content-Type") {
162            if content_type != "image/jpeg" && content_type != "image/png" {
163                return Err(Error::UrlIsNotAnImage);
164            }
165        } else {
166            return Err(Error::UrlIsNotAnImage);
167        }
168
169        res.bytes().await.map_err(|_| Error::UrlIsNotAnImage)
170    }
171
172    async fn check_user_quota(&self, user_id: &UserId) -> Result<(), Error> {
173        if let Some(quota) = self.user_quotas.read().await.get(user_id) {
174            if quota.is_reached() {
175                return Err(Error::UserQuotaMet);
176            }
177        }
178
179        Ok(())
180    }
181
182    async fn check_image_size(&self, image_bytes: &Bytes) -> Result<(), Error> {
183        let settings = self.cfg.settings.read().await;
184
185        if image_bytes.len() > settings.image_cache.entry_size_limit {
186            return Err(Error::ImageTooBig);
187        }
188
189        Ok(())
190    }
191
192    async fn update_image_cache(&self, url: &str, image_bytes: &Bytes) -> Result<(), Error> {
193        if self
194            .image_cache
195            .write()
196            .await
197            .set(url.to_string(), image_bytes.clone())
198            .await
199            .is_err()
200        {
201            return Err(Error::ImageTooBig);
202        }
203
204        Ok(())
205    }
206
207    async fn update_user_quota(&self, user_id: &UserId, amount: usize) -> Result<(), Error> {
208        let settings = self.cfg.settings.read().await;
209
210        let mut quota = self
211            .user_quotas
212            .read()
213            .await
214            .get(user_id)
215            .cloned()
216            .unwrap_or(ImageCacheQuota::new(
217                *user_id,
218                settings.image_cache.user_quota_bytes,
219                settings.image_cache.user_quota_period_seconds,
220            ));
221
222        let _ = quota.add_usage(amount);
223
224        let _ = self.user_quotas.write().await.insert(*user_id, quota);
225
226        Ok(())
227    }
228}