salvo_captcha/storage/
cacache_storage.rs

1// Copyright (c) 2024-2025, Awiteb <a@4rs.nl>
2//     A captcha middleware for Salvo framework.
3//
4// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
5// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
6// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
7// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
8// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
9// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
10// THE SOFTWARE.
11
12use std::{
13    path::{Path, PathBuf},
14    time::{Duration, SystemTime},
15};
16
17use crate::CaptchaStorage;
18
19/// The [`cacache`] storage. Store the token and answer in the disk.
20///
21/// [`cacache`]: https://github.com/zkat/cacache-rs
22#[derive(Debug, Clone)]
23pub struct CacacheStorage {
24    /// The cacache cache directory.
25    cache_dir: PathBuf,
26}
27
28impl CacacheStorage {
29    /// Create a new [`CacacheStorage`] instance with the cache directory.
30    pub fn new(cache_dir: impl Into<PathBuf>) -> Self {
31        Self {
32            cache_dir: cache_dir.into(),
33        }
34    }
35
36    /// Get the cacache cache directory.
37    pub fn cache_dir(&self) -> &Path {
38        &self.cache_dir
39    }
40}
41
42impl CaptchaStorage for CacacheStorage {
43    type Error = cacache::Error;
44
45    async fn store_answer(&self, answer: String) -> Result<String, Self::Error> {
46        let token = uuid::Uuid::new_v4();
47        log::info!("Storing captcha answer to cacache for token: {token}");
48        cacache::write(&self.cache_dir, token.to_string(), answer.as_bytes()).await?;
49        Ok(token.to_string())
50    }
51
52    async fn get_answer(&self, token: &str) -> Result<Option<String>, Self::Error> {
53        log::info!("Getting captcha answer from cacache for token: {token}");
54        match cacache::read(&self.cache_dir, token).await {
55            Ok(answer) => {
56                log::info!("Captcha answer is exist in cacache for token: {token}");
57                Ok(Some(
58                    String::from_utf8(answer)
59                        .expect("All the stored captcha answer should be utf8"),
60                ))
61            }
62            Err(cacache::Error::EntryNotFound(_, _)) => {
63                log::info!("Captcha answer is not exist in cacache for token: {token}");
64                Ok(None)
65            }
66            Err(err) => {
67                log::error!("Failed to get captcha answer from cacache for token: {token}");
68                Err(err)
69            }
70        }
71    }
72
73    async fn clear_expired(&self, expired_after: Duration) -> Result<(), Self::Error> {
74        let now = SystemTime::now()
75            .duration_since(std::time::UNIX_EPOCH)
76            .expect("SystemTime before UNIX EPOCH!")
77            .as_millis();
78        let expired_after = expired_after.as_millis();
79
80        let expr_keys = cacache::index::ls(&self.cache_dir).filter_map(|meta| {
81            if let Ok(meta) = meta {
82                if now >= (meta.time + expired_after) {
83                    return Some(meta.key);
84                }
85            }
86            None
87        });
88
89        for key in expr_keys {
90            cacache::RemoveOpts::new()
91                .remove_fully(true)
92                .remove(&self.cache_dir, &key)
93                .await
94                .ok();
95        }
96        Ok(())
97    }
98
99    async fn clear_by_token(&self, token: &str) -> Result<(), Self::Error> {
100        log::info!("Clearing captcha token from cacache: {token}");
101        let remove_opts = cacache::RemoveOpts::new().remove_fully(true);
102        remove_opts.remove(&self.cache_dir, token).await
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[tokio::test]
111    async fn cacache_store_captcha() {
112        let storage = CacacheStorage::new(
113            tempfile::tempdir()
114                .expect("failed to create temp file")
115                .path()
116                .to_owned(),
117        );
118
119        let token = storage
120            .store_answer("answer".to_owned())
121            .await
122            .expect("failed to store captcha");
123        assert_eq!(
124            storage
125                .get_answer(&token)
126                .await
127                .expect("failed to get captcha answer"),
128            Some("answer".to_owned())
129        );
130    }
131
132    #[tokio::test]
133    async fn cacache_clear_expired() {
134        let storage = CacacheStorage::new(
135            tempfile::tempdir()
136                .expect("failed to create temp file")
137                .path()
138                .to_owned(),
139        );
140
141        let token = storage
142            .store_answer("answer".to_owned())
143            .await
144            .expect("failed to store captcha");
145        storage
146            .clear_expired(Duration::from_secs(0))
147            .await
148            .expect("failed to clear expired captcha");
149        assert!(storage
150            .get_answer(&token)
151            .await
152            .expect("failed to get captcha answer")
153            .is_none());
154    }
155
156    #[tokio::test]
157    async fn cacache_clear_by_token() {
158        let storage = CacacheStorage::new(
159            tempfile::tempdir()
160                .expect("failed to create temp file")
161                .path()
162                .to_owned(),
163        );
164
165        let token = storage
166            .store_answer("answer".to_owned())
167            .await
168            .expect("failed to store captcha");
169        storage
170            .clear_by_token(&token)
171            .await
172            .expect("failed to clear captcha by token");
173        assert!(storage
174            .get_answer(&token)
175            .await
176            .expect("failed to get captcha answer")
177            .is_none());
178    }
179
180    #[tokio::test]
181    async fn cacache_is_token_exist() {
182        let storage = CacacheStorage::new(
183            tempfile::tempdir()
184                .expect("failed to create temp file")
185                .path()
186                .to_owned(),
187        );
188
189        let token = storage
190            .store_answer("answer".to_owned())
191            .await
192            .expect("failed to store captcha");
193        assert!(storage
194            .get_answer(&token)
195            .await
196            .expect("failed to check if token is exist")
197            .is_some());
198        assert!(storage
199            .get_answer("token")
200            .await
201            .expect("failed to check if token is exist")
202            .is_none());
203    }
204
205    #[tokio::test]
206    async fn cacache_get_answer() {
207        let storage = CacacheStorage::new(
208            tempfile::tempdir()
209                .expect("failed to create temp file")
210                .path()
211                .to_owned(),
212        );
213
214        let token = storage
215            .store_answer("answer".to_owned())
216            .await
217            .expect("failed to store captcha");
218        assert_eq!(
219            storage
220                .get_answer(&token)
221                .await
222                .expect("failed to get captcha answer"),
223            Some("answer".to_owned())
224        );
225        assert!(storage
226            .get_answer("token")
227            .await
228            .expect("failed to get captcha answer")
229            .is_none());
230    }
231
232    #[tokio::test]
233    async fn cacache_cache_dir() {
234        let cache_dir = tempfile::tempdir()
235            .expect("failed to create temp file")
236            .path()
237            .to_owned();
238        let storage = CacacheStorage::new(cache_dir.clone());
239        assert_eq!(storage.cache_dir(), &cache_dir);
240    }
241
242    #[tokio::test]
243    async fn cacache_clear_expired_with_expired_after() {
244        let storage = CacacheStorage::new(
245            tempfile::tempdir()
246                .expect("failed to create temp file")
247                .path()
248                .to_owned(),
249        );
250
251        let token = storage
252            .store_answer("answer".to_owned())
253            .await
254            .expect("failed to store captcha");
255        storage
256            .clear_expired(Duration::from_secs(1))
257            .await
258            .expect("failed to clear expired captcha");
259        assert_eq!(
260            storage
261                .get_answer(&token)
262                .await
263                .expect("failed to get captcha answer"),
264            Some("answer".to_owned())
265        );
266        tokio::time::sleep(Duration::from_secs(1)).await;
267        storage
268            .clear_expired(Duration::from_secs(1))
269            .await
270            .expect("failed to clear expired captcha");
271        assert!(storage
272            .get_answer(&token)
273            .await
274            .expect("failed to get captcha answer")
275            .is_none());
276    }
277}