backbone_bucket/presentation/http/
serving.rs1use 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
35pub 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
65pub 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 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
107pub(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 (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
147pub 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 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 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 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}