use std::sync::Arc;
use axum::extract::{Extension, Path};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use axum::{routing::get, Json, Router};
use serde_json::json;
use crate::auth::{ArcAuthzPolicy, AuthExtractor, HasOwnerId};
use crate::config::{BucketConfig, ServingMode};
use crate::domain::entity::StoredFile;
use crate::error::{BucketError, BucketResult};
use crate::infrastructure::persistence::StoredFileRepository;
use crate::storage::ObjectStorage;
pub struct ServingContext<Identity>
where
Identity: Send + Sync + 'static,
{
pub storage: Arc<dyn ObjectStorage>,
pub file_repo: Arc<StoredFileRepository>,
pub authz: ArcAuthzPolicy<Identity>,
pub config: Arc<BucketConfig>,
}
impl<I> Clone for ServingContext<I>
where
I: Send + Sync + 'static,
{
fn clone(&self) -> Self {
Self {
storage: self.storage.clone(),
file_repo: self.file_repo.clone(),
authz: self.authz.clone(),
config: self.config.clone(),
}
}
}
pub fn serving_router<A>(ctx: ServingContext<A>) -> Router
where
A: AuthExtractor<()> + HasOwnerId + 'static,
A::Rejection: IntoResponse,
{
Router::new()
.route("/*key", get(serve_file::<A>))
.layer(Extension(Arc::new(ctx)))
}
async fn serve_file<A>(
Extension(ctx): Extension<Arc<ServingContext<A>>>,
identity: A,
Path(key): Path<String>,
) -> Result<Response, BucketError>
where
A: AuthExtractor<()> + HasOwnerId + 'static,
A::Rejection: IntoResponse,
{
let file = lookup_by_key(&ctx.file_repo, &key).await?;
ctx.authz.ensure_can_read(&identity, &file).await?;
build_mode_response(&*ctx.storage, &ctx.config, &file).await
}
pub(crate) async fn build_mode_response(
storage: &dyn ObjectStorage,
config: &BucketConfig,
file: &StoredFile,
) -> Result<Response, BucketError> {
let serving = &config.serving;
match serving.default_mode {
ServingMode::Redirect => {
let url = storage
.presigned_get(&file.storage_key, serving.presigned_ttl)
.await?;
Ok(Redirect::temporary(url.as_str()).into_response())
}
ServingMode::Stream => {
let bytes = storage.get(&file.storage_key).await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, file.mime_type.clone())],
bytes,
)
.into_response())
}
ServingMode::SignedUrl => {
let url = storage
.presigned_get(&file.storage_key, serving.presigned_ttl)
.await?;
Ok(Json(json!({ "url": url.to_string() })).into_response())
}
}
}
pub async fn lookup_by_key(
repo: &StoredFileRepository,
key: &str,
) -> BucketResult<StoredFile> {
repo.find_by_text_field("storage_key", key)
.await
.map_err(|e| BucketError::Other(e.to_string()))?
.ok_or(BucketError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use async_trait::async_trait;
use axum::body::to_bytes;
use axum::http::StatusCode;
use bytes::Bytes;
use url::Url;
use uuid::Uuid;
use crate::config::{BucketConfig, ServingConfig, ServingMode, StorageConfig};
use crate::domain::entity::{AuditMetadata, FileStatus, StoredFile};
use crate::storage::{ObjectMeta, ObjectStorage};
struct StubStorage {
body: Bytes,
presigned: Url,
}
#[async_trait]
impl ObjectStorage for StubStorage {
async fn put(&self, _key: &str, _body: Bytes, _ct: &str) -> BucketResult<()> {
Ok(())
}
async fn get(&self, _key: &str) -> BucketResult<Bytes> {
Ok(self.body.clone())
}
async fn delete(&self, _key: &str) -> BucketResult<()> {
Ok(())
}
async fn head(&self, key: &str) -> BucketResult<ObjectMeta> {
Ok(ObjectMeta {
key: key.to_string(),
size: self.body.len() as u64,
content_type: None,
etag: None,
last_modified: None,
})
}
async fn presigned_get(&self, _key: &str, _ttl: Duration) -> BucketResult<Url> {
Ok(self.presigned.clone())
}
async fn presigned_put(
&self,
_key: &str,
_ttl: Duration,
_ct: &str,
) -> BucketResult<Url> {
Ok(self.presigned.clone())
}
fn public_url(&self, _key: &str) -> Option<Url> {
None
}
}
fn fixture_file() -> StoredFile {
StoredFile::new(
Uuid::new_v4(),
Uuid::new_v4(),
"/docs/a.txt".into(),
"a.txt".into(),
5,
"text/plain".into(),
false,
false,
false,
false,
false,
0,
FileStatus::Active,
"docs/a.txt".into(),
1,
0,
)
}
fn fixture_config(mode: ServingMode) -> BucketConfig {
BucketConfig {
enabled: true,
storage: StorageConfig::Local {
root: "/tmp/bucket-test".into(),
base_url: Url::parse("http://localhost/cdn/").unwrap(),
signing_secret_env: "UNUSED".into(),
},
serving: ServingConfig {
default_mode: mode,
public_prefix: "public/".into(),
presigned_ttl: Duration::from_secs(60),
},
}
}
fn stub() -> StubStorage {
StubStorage {
body: Bytes::from_static(b"hello"),
presigned: Url::parse("https://minio.example/docs/a.txt?X-Amz-Signature=abc").unwrap(),
}
}
#[tokio::test]
async fn redirect_mode_returns_302_to_presigned_url() {
let storage = stub();
let cfg = fixture_config(ServingMode::Redirect);
let file = fixture_file();
let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap();
assert_eq!(loc, storage.presigned.as_str());
}
#[tokio::test]
async fn stream_mode_returns_body_with_content_type() {
let storage = stub();
let cfg = fixture_config(ServingMode::Stream);
let file = fixture_file();
let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/plain"
);
let body = to_bytes(resp.into_body(), 1024).await.unwrap();
assert_eq!(&body[..], b"hello");
}
#[tokio::test]
async fn signed_url_mode_returns_json_with_url() {
let storage = stub();
let cfg = fixture_config(ServingMode::SignedUrl);
let file = fixture_file();
let resp = build_mode_response(&storage, &cfg, &file).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), 4096).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["url"], storage.presigned.as_str());
}
#[test]
fn error_response_masks_internal_details() {
let resp = BucketError::S3("AWS secret leak: AKIA...".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn error_response_preserves_user_facing_status() {
assert_eq!(
BucketError::NotFound.into_response().status(),
StatusCode::NOT_FOUND
);
assert_eq!(
BucketError::Forbidden.into_response().status(),
StatusCode::FORBIDDEN
);
assert_eq!(
BucketError::Unauthenticated.into_response().status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
BucketError::InvalidSignature.into_response().status(),
StatusCode::FORBIDDEN
);
}
}
impl IntoResponse for BucketError {
fn into_response(self) -> Response {
match &self {
BucketError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()).into_response(),
BucketError::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string()).into_response(),
BucketError::Unauthenticated => {
(StatusCode::UNAUTHORIZED, "unauthenticated".to_string()).into_response()
}
BucketError::InvalidSignature => {
(StatusCode::FORBIDDEN, "invalid signature".to_string()).into_response()
}
BucketError::Unsupported(msg) => (
StatusCode::NOT_IMPLEMENTED,
format!("operation not supported: {msg}"),
)
.into_response(),
BucketError::InvalidInput(msg) => {
(StatusCode::BAD_REQUEST, msg.clone()).into_response()
}
BucketError::Conflict(msg) => {
(StatusCode::CONFLICT, msg.clone()).into_response()
}
BucketError::PayloadTooLarge(msg) => {
(StatusCode::PAYLOAD_TOO_LARGE, msg.clone()).into_response()
}
BucketError::UnsupportedMediaType(msg) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone()).into_response()
}
BucketError::Config(_)
| BucketError::Io(_)
| BucketError::S3(_)
| BucketError::Url(_)
| BucketError::Other(_) => {
tracing::error!(error = %self, "bucket handler failed");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
.into_response()
}
}
}
}