Skip to main content

fraiseql_storage/service/
mod.rs

1//! Policy-enforcing wrapper around storage backends.
2//!
3//! `BucketService` validates size limits and MIME type restrictions
4//! before delegating to the underlying storage backend.
5
6use std::time::Duration;
7
8use fraiseql_error::{FileError, FraiseQLError, Result};
9
10use super::backend::validate_key;
11use crate::{
12    backend::{StorageBackend, types::ListResult},
13    config::BucketConfig,
14};
15
16/// A bucket-aware storage service that enforces policies.
17///
18/// Wraps a `StorageBackend` with a `BucketConfig` to validate
19/// upload size and MIME type restrictions before delegating operations.
20pub struct BucketService {
21    backend: StorageBackend,
22    config:  BucketConfig,
23}
24
25impl BucketService {
26    /// Creates a new bucket service.
27    #[must_use]
28    pub const fn new(backend: StorageBackend, config: BucketConfig) -> Self {
29        Self { backend, config }
30    }
31
32    /// Returns a reference to the bucket configuration.
33    #[must_use]
34    pub const fn config(&self) -> &BucketConfig {
35        &self.config
36    }
37
38    /// Uploads data with policy validation.
39    ///
40    /// Validates:
41    /// - Key is safe (no path traversal)
42    /// - Data size does not exceed configured limit
43    /// - Content type is in allowed MIME types list
44    ///
45    /// # Errors
46    ///
47    /// Returns `FraiseQLError::File` if validation fails or upload fails.
48    pub async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> Result<String> {
49        validate_key(key)?;
50
51        // Validate size limit
52        if let Some(max_bytes) = self.config.max_object_bytes {
53            // Reason: data.len() is bounded by axum body limits + this check; the cast to
54            // u64 is wider than usize on all supported targets, so no truncation can occur.
55            #[allow(clippy::cast_possible_truncation)]
56            let actual = data.len() as u64;
57            if actual > max_bytes {
58                return Err(FraiseQLError::File(FileError::SizeLimitExceeded {
59                    message: format!("Upload exceeds maximum object size of {max_bytes} bytes"),
60                    limit:   Some(max_bytes),
61                    actual:  Some(actual),
62                }));
63            }
64        }
65
66        // Validate MIME type
67        if let Some(ref allowed) = self.config.allowed_mime_types {
68            let is_allowed = allowed.iter().any(|m| m == content_type || m == "*/*");
69            if !is_allowed {
70                return Err(FraiseQLError::File(FileError::MimeTypeNotAllowed {
71                    message: format!(
72                        "Content type '{content_type}' is not allowed for this bucket"
73                    ),
74                    mime:    Some(content_type.to_string()),
75                }));
76            }
77        }
78
79        self.backend.upload(key, data, content_type).await
80    }
81
82    /// Downloads data without policy validation (policies only apply to upload).
83    ///
84    /// # Errors
85    ///
86    /// Returns `FraiseQLError::File` if download fails.
87    pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
88        self.backend.download(key).await
89    }
90
91    /// Deletes an object.
92    ///
93    /// # Errors
94    ///
95    /// Returns `FraiseQLError::File` if deletion fails.
96    pub async fn delete(&self, key: &str) -> Result<()> {
97        self.backend.delete(key).await
98    }
99
100    /// Checks if an object exists.
101    ///
102    /// # Errors
103    ///
104    /// Returns `FraiseQLError::File` on backend communication errors.
105    pub async fn exists(&self, key: &str) -> Result<bool> {
106        self.backend.exists(key).await
107    }
108
109    /// Lists objects in the bucket by prefix.
110    ///
111    /// # Errors
112    ///
113    /// Returns `FraiseQLError::File` if listing fails.
114    pub async fn list(
115        &self,
116        prefix: &str,
117        cursor: Option<&str>,
118        limit: usize,
119    ) -> Result<ListResult> {
120        self.backend.list(prefix, cursor, limit).await
121    }
122
123    /// Generates a presigned URL for direct access to an object.
124    ///
125    /// # Errors
126    ///
127    /// Returns `FraiseQLError::File` if presigned URLs are not supported
128    /// by the backend or if generation fails.
129    pub async fn presigned_url(&self, key: &str, expiry: Duration) -> Result<String> {
130        self.backend.presigned_url(key, expiry).await
131    }
132}
133
134#[cfg(test)]
135mod tests;