Skip to main content

backbone_bucket/presentation/http/
serving.rs

1//! Mode-B serving handler.
2//!
3//! Serves files under `/*key` with the consumer's auth extractor and
4//! authorization policy in the path. Response strategy is driven by
5//! [`ServingMode`](crate::config::ServingMode):
6//!
7//! - `Redirect` (default) — 302 to a short-lived presigned URL.
8//! - `Stream` — proxy the bytes through the service.
9//! - `SignedUrl` — return `{"url": "..."}` JSON.
10//!
11//! Authentication and authorization are pluggable:
12//!
13//! - [`AuthExtractor`] resolves the caller's identity from the request.
14//! - [`AuthzPolicy`] decides whether that identity may read the file.
15//!
16//! The context object travels as an Axum [`Extension`] layer, which keeps
17//! the Router generic over `()` so consumers can merge the serving router
18//! into their existing app without wrestling with shared-state generics.
19
20use std::sync::Arc;
21
22use axum::extract::{Extension, Path};
23use axum::http::{header, StatusCode};
24use axum::response::{IntoResponse, Redirect, Response};
25use axum::{routing::get, Json, Router};
26use serde_json::json;
27
28use crate::auth::{ArcAuthzPolicy, AuthExtractor, HasOwnerId};
29use crate::config::{BucketConfig, ServingMode};
30use crate::domain::entity::StoredFile;
31use crate::error::{BucketError, BucketResult};
32use crate::infrastructure::persistence::StoredFileRepository;
33use crate::storage::ObjectStorage;
34
35/// Shared handler state — travels as an Axum `Extension`.
36///
37/// Parameterized over the consumer's identity type (`A`, which in Axum
38/// doubles as both extractor and identity) so the handler can call the
39/// consumer's [`AuthzPolicy`] without the module knowing what the
40/// identity actually looks like.
41pub struct ServingContext<Identity>
42where
43    Identity: Send + Sync + 'static,
44{
45    pub storage: Arc<dyn ObjectStorage>,
46    pub file_repo: Arc<StoredFileRepository>,
47    pub authz: ArcAuthzPolicy<Identity>,
48    pub config: Arc<BucketConfig>,
49}
50
51impl<I> Clone for ServingContext<I>
52where
53    I: Send + Sync + 'static,
54{
55    fn clone(&self) -> Self {
56        Self {
57            storage: self.storage.clone(),
58            file_repo: self.file_repo.clone(),
59            authz: self.authz.clone(),
60            config: self.config.clone(),
61        }
62    }
63}
64
65/// Build the serving router.
66///
67/// Consumers mount this at whatever path suits their URL layout —
68/// `/cdn` is the default recommendation:
69///
70/// ```ignore
71/// let app = Router::new()
72///     .merge(bucket.crud_router())
73///     .nest("/cdn", bucket.serving_router::<MyAuth>(policy)?);
74/// ```
75///
76/// `A` is the consumer's [`AuthExtractor`]; Axum resolves it on every
77/// request to produce the identity that flows into the authz policy.
78pub fn serving_router<A>(ctx: ServingContext<A>) -> Router
79where
80    A: AuthExtractor<()> + HasOwnerId + 'static,
81    A::Rejection: IntoResponse,
82{
83    Router::new()
84        .route("/*key", get(serve_file::<A>))
85        .layer(Extension(Arc::new(ctx)))
86}
87
88async fn serve_file<A>(
89    Extension(ctx): Extension<Arc<ServingContext<A>>>,
90    identity: A,
91    Path(key): Path<String>,
92) -> Result<Response, BucketError>
93where
94    A: AuthExtractor<()> + HasOwnerId + 'static,
95    A::Rejection: IntoResponse,
96{
97    let file = lookup_by_key(&ctx.file_repo, &key).await?;
98    // Only a file that finished uploading and passed its checks is served: one still uploading
99    // or processing, or quarantined, reads as absent.
100    if file.status != crate::domain::entity::FileStatus::Active {
101        return Err(BucketError::NotFound);
102    }
103    ctx.authz.ensure_can_read(&identity, &file).await?;
104    build_mode_response(&*ctx.storage, &ctx.config, &file).await
105}
106
107/// Build the serving response for a single file according to the
108/// configured [`ServingMode`].
109///
110/// Split out of `serve_file` so unit tests can exercise each mode
111/// without spinning up a database or Axum router.
112pub(crate) async fn build_mode_response(
113    storage: &dyn ObjectStorage,
114    config: &BucketConfig,
115    file: &StoredFile,
116) -> Result<Response, BucketError> {
117    let serving = &config.serving;
118    match serving.default_mode {
119        ServingMode::Redirect => {
120            let url = storage
121                .presigned_get(&file.storage_key, serving.presigned_ttl)
122                .await?;
123            Ok(Redirect::temporary(url.as_str()).into_response())
124        }
125        ServingMode::Stream => {
126            let bytes = storage.get(&file.storage_key).await?;
127            Ok((
128                StatusCode::OK,
129                [
130                    (header::CONTENT_TYPE, file.mime_type.clone()),
131                    // The type is what the uploader declared; never let a browser guess another.
132                    (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
133                ],
134                bytes,
135            )
136                .into_response())
137        }
138        ServingMode::SignedUrl => {
139            let url = storage
140                .presigned_get(&file.storage_key, serving.presigned_ttl)
141                .await?;
142            Ok(Json(json!({ "url": url.to_string() })).into_response())
143        }
144    }
145}
146
147/// Look up a file by its `storage_key` column.
148///
149/// Exposed as a free fn so call sites outside the handler (tests, custom
150/// routes) can reuse the same lookup rule.
151pub async fn lookup_by_key(
152    repo: &StoredFileRepository,
153    key: &str,
154) -> BucketResult<StoredFile> {
155    repo.find_by_text_field("storage_key", key)
156        .await
157        .map_err(|e| BucketError::Other(e.to_string()))?
158        .ok_or(BucketError::NotFound)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::time::Duration;
165
166    use async_trait::async_trait;
167    use axum::body::to_bytes;
168    use axum::http::StatusCode;
169    use bytes::Bytes;
170    use url::Url;
171    use uuid::Uuid;
172
173    use crate::config::{BucketConfig, ServingConfig, ServingMode, StorageConfig};
174    use crate::domain::entity::{AuditMetadata, FileStatus, StoredFile};
175    use crate::storage::{ObjectMeta, ObjectStorage};
176
177    /// In-memory ObjectStorage stub for mode-branch tests.
178    ///
179    /// Records the keys it was asked for so assertions can confirm the
180    /// handler called the right method. No filesystem or network.
181    struct StubStorage {
182        body: Bytes,
183        presigned: Url,
184    }
185
186    #[async_trait]
187    impl ObjectStorage for StubStorage {
188        async fn put(&self, _key: &str, _body: Bytes, _ct: &str) -> BucketResult<()> {
189            Ok(())
190        }
191        async fn get(&self, _key: &str) -> BucketResult<Bytes> {
192            Ok(self.body.clone())
193        }
194        async fn delete(&self, _key: &str) -> BucketResult<()> {
195            Ok(())
196        }
197        async fn head(&self, key: &str) -> BucketResult<ObjectMeta> {
198            Ok(ObjectMeta {
199                key: key.to_string(),
200                size: self.body.len() as u64,
201                content_type: None,
202                etag: None,
203                last_modified: None,
204            })
205        }
206        async fn presigned_get(&self, _key: &str, _ttl: Duration) -> BucketResult<Url> {
207            Ok(self.presigned.clone())
208        }
209        async fn presigned_put(
210            &self,
211            _key: &str,
212            _ttl: Duration,
213            _ct: &str,
214        ) -> BucketResult<Url> {
215            Ok(self.presigned.clone())
216        }
217        fn public_url(&self, _key: &str) -> Option<Url> {
218            None
219        }
220    }
221
222    fn fixture_file() -> StoredFile {
223        StoredFile::new(
224            Uuid::new_v4(),
225            Uuid::new_v4(),
226            "/docs/a.txt".into(),
227            "a.txt".into(),
228            5,
229            "text/plain".into(),
230            false,
231            false,
232            false,
233            false,
234            false,
235            0,
236            FileStatus::Active,
237            "docs/a.txt".into(),
238            1,
239            0,
240        )
241    }
242
243    fn fixture_config(mode: ServingMode) -> BucketConfig {
244        BucketConfig {
245            enabled: true,
246            storage: StorageConfig::Local {
247                root: "/tmp/bucket-test".into(),
248                base_url: Url::parse("http://localhost/cdn/").unwrap(),
249                signing_secret_env: "UNUSED".into(),
250            },
251            serving: ServingConfig {
252                default_mode: mode,
253                public_prefix: "public/".into(),
254                presigned_ttl: Duration::from_secs(60),
255            },
256        }
257    }
258
259    fn stub() -> StubStorage {
260        StubStorage {
261            body: Bytes::from_static(b"hello"),
262            presigned: Url::parse("https://minio.example/docs/a.txt?X-Amz-Signature=abc").unwrap(),
263        }
264    }
265
266    #[tokio::test]
267    async fn redirect_mode_returns_302_to_presigned_url() {
268        let storage = stub();
269        let cfg = fixture_config(ServingMode::Redirect);
270        let file = fixture_file();
271
272        let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
273        assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
274        let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap();
275        assert_eq!(loc, storage.presigned.as_str());
276    }
277
278    #[tokio::test]
279    async fn stream_mode_returns_body_with_content_type() {
280        let storage = stub();
281        let cfg = fixture_config(ServingMode::Stream);
282        let file = fixture_file();
283
284        let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
285        assert_eq!(resp.status(), StatusCode::OK);
286        assert_eq!(
287            resp.headers().get(header::CONTENT_TYPE).unwrap(),
288            "text/plain"
289        );
290        let body = to_bytes(resp.into_body(), 1024).await.unwrap();
291        assert_eq!(&body[..], b"hello");
292    }
293
294    #[tokio::test]
295    async fn signed_url_mode_returns_json_with_url() {
296        let storage = stub();
297        let cfg = fixture_config(ServingMode::SignedUrl);
298        let file = fixture_file();
299
300        let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
301        assert_eq!(resp.status(), StatusCode::OK);
302        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
303        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
304        assert_eq!(json["url"], storage.presigned.as_str());
305    }
306
307    #[test]
308    fn error_response_masks_internal_details() {
309        // S3 / Other / Io / Url / Config all collapse to a generic 500.
310        let resp = BucketError::S3("AWS secret leak: AKIA...".into()).into_response();
311        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
312    }
313
314    #[test]
315    fn error_response_preserves_user_facing_status() {
316        assert_eq!(
317            BucketError::NotFound.into_response().status(),
318            StatusCode::NOT_FOUND
319        );
320        assert_eq!(
321            BucketError::Forbidden.into_response().status(),
322            StatusCode::FORBIDDEN
323        );
324        assert_eq!(
325            BucketError::Unauthenticated.into_response().status(),
326            StatusCode::UNAUTHORIZED
327        );
328        assert_eq!(
329            BucketError::InvalidSignature.into_response().status(),
330            StatusCode::FORBIDDEN
331        );
332    }
333}
334
335impl IntoResponse for BucketError {
336    fn into_response(self) -> Response {
337        // 4xx variants carry caller-facing detail; 5xx variants are
338        // collapsed to a generic message to avoid leaking backend
339        // internals. The full error is captured in `tracing` for operators.
340        match &self {
341            BucketError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()).into_response(),
342            BucketError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string()).into_response(),
343            BucketError::Unauthenticated => {
344                (StatusCode::UNAUTHORIZED, "unauthenticated".to_string()).into_response()
345            }
346            BucketError::InvalidSignature => {
347                (StatusCode::FORBIDDEN, "invalid signature".to_string()).into_response()
348            }
349            BucketError::Unsupported(msg) => (
350                StatusCode::NOT_IMPLEMENTED,
351                format!("operation not supported: {msg}"),
352            )
353                .into_response(),
354            BucketError::InvalidInput(msg) => {
355                (StatusCode::BAD_REQUEST, msg.clone()).into_response()
356            }
357            BucketError::Conflict(msg) => {
358                (StatusCode::CONFLICT, msg.clone()).into_response()
359            }
360            BucketError::PayloadTooLarge(msg) => {
361                (StatusCode::PAYLOAD_TOO_LARGE, msg.clone()).into_response()
362            }
363            BucketError::UnsupportedMediaType(msg) => {
364                (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone()).into_response()
365            }
366            BucketError::Config(_)
367            | BucketError::Io(_)
368            | BucketError::S3(_)
369            | BucketError::Url(_)
370            | BucketError::Other(_) => {
371                tracing::error!(error = %self, "bucket handler failed");
372                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
373                    .into_response()
374            }
375        }
376    }
377}