use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use futures_util::StreamExt;
use crate::auth::Permission;
use crate::error::Error;
use crate::model::{Actions, BatchRequest, BatchResponse, ObjectId, ObjectSpec, Operation};
use crate::namespace::Namespace;
use crate::range::Range;
use crate::state::Shared;
use crate::storage::Budget;
pub(super) async fn batch(
State(state): State<Shared>,
Extension(ns): Extension<Namespace>,
Extension(permission): Extension<Permission>,
headers: axum::http::HeaderMap,
Json(request): Json<BatchRequest>,
) -> Result<Json<BatchResponse>, Error> {
if request.operation == Operation::Upload {
permission.require_write()?;
}
let base = state.config.base_url(&headers);
let budget = match state.config.repo_quota {
Some(limit) if request.operation == Operation::Upload => {
let (_, used) = state.store.usage_of(&ns).await;
Some(Budget { used, limit })
}
_ => None,
};
let mut objects = Vec::with_capacity(request.objects.len());
for id in request.objects {
objects.push(match request.operation {
Operation::Download => resolve_download(&state, &base, &ns, id).await,
Operation::Upload => resolve_upload(&state, &base, &ns, id, budget).await,
});
}
Ok(Json(BatchResponse {
transfer: negotiate(&request.transfers),
objects,
}))
}
fn negotiate(advertised: &[String]) -> &'static str {
const BASIC: &str = "basic";
if !advertised.is_empty() && !advertised.iter().any(|transfer| transfer == BASIC) {
tracing::debug!(
?advertised,
"client advertised no adapter this server implements, answering basic"
);
}
BASIC
}
async fn resolve_download(state: &Shared, base: &str, ns: &Namespace, id: ObjectId) -> ObjectSpec {
if !state.store.exists(ns, &id.oid).await {
return ObjectSpec::missing(id);
}
let (href, authenticated) = match state.store.redirect(&id.oid) {
Some(signed) => (signed, Some(true)),
None => (state.config.object_url(base, ns, &id.oid), None),
};
ObjectSpec {
id,
authenticated,
actions: Some(Actions {
download: Some(state.config.action(href)),
..Actions::default()
}),
error: None,
}
}
async fn resolve_upload(
state: &Shared,
base: &str,
ns: &Namespace,
id: ObjectId,
budget: Option<Budget>,
) -> ObjectSpec {
if let Some(limit) = state
.config
.max_object_size
.filter(|limit| id.size > *limit)
{
return ObjectSpec::too_large(id, limit);
}
if state.store.exists(ns, &id.oid).await {
return ObjectSpec {
id,
authenticated: None,
actions: None,
error: None,
};
}
if let Some(budget) = budget.filter(|budget| budget.exceeded_by(id.size)) {
return ObjectSpec::over_quota(id, budget.used, budget.limit);
}
let verify = state.config.verify_url(base, ns);
if let Some(signed) = state.store.presigned_upload(ns, &id.oid) {
return ObjectSpec {
id,
authenticated: Some(true),
actions: Some(Actions {
upload: Some(state.config.signed_action(signed.href, signed.headers)),
verify: Some(state.config.action(verify)),
..Actions::default()
}),
error: None,
};
}
let upload = state.config.object_url(base, ns, &id.oid);
ObjectSpec {
id,
authenticated: None,
actions: Some(Actions {
upload: Some(state.config.action(upload)),
verify: Some(state.config.action(verify)),
..Actions::default()
}),
error: None,
}
}
pub(super) async fn upload(
State(state): State<Shared>,
Extension(ns): Extension<Namespace>,
Extension(permission): Extension<Permission>,
Path((.., oid)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
body: Body,
) -> Result<StatusCode, Error> {
permission.require_write()?;
let size = headers
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
let budget = match state.config.repo_quota {
Some(limit) if !state.store.exists(&ns, &oid).await => {
let (_, used) = state.store.usage_of(&ns).await;
let budget = Budget { used, limit };
if budget.exceeded_by(size.unwrap_or_default()) {
return Err(budget.refusal());
}
Some(budget)
}
_ => None,
};
let written = state
.store
.write(&ns, &oid, size, budget, body.into_data_stream())
.await?;
state.metrics.uploaded_bytes.inc_by(written);
state.metrics.object_size.observe(written as f64);
Ok(StatusCode::OK)
}
pub(super) async fn download(
State(state): State<Shared>,
Extension(ns): Extension<Namespace>,
Path((.., oid)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
) -> Result<Response, Error> {
let object = state.store.open(&ns, &oid).await?;
let size = object.size();
let requested = headers
.get(header::RANGE)
.and_then(|value| value.to_str().ok());
let range = Range::parse(requested, size);
if range == Range::Unsatisfiable {
return Ok((
StatusCode::RANGE_NOT_SATISFIABLE,
[(header::CONTENT_RANGE, format!("bytes */{size}"))],
)
.into_response());
}
let start = match range {
Range::Slice { start, .. } => start,
_ => 0,
};
let length = range.length(size);
let counted = state.clone();
let chunks = object.stream(start, length).await?;
let body = Body::from_stream(chunks.inspect(move |chunk| {
if let Ok(bytes) = chunk {
counted.metrics.downloaded_bytes.inc_by(bytes.len() as u64);
}
}));
let mut response = (
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
),
(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")),
(header::CONTENT_LENGTH, HeaderValue::from(length)),
],
body,
)
.into_response();
if let Range::Slice { start, end } = range {
response
.headers_mut()
.insert(header::CONTENT_RANGE, content_range(start, end, size));
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
}
Ok(response)
}
fn content_range(start: u64, end: u64, size: u64) -> HeaderValue {
HeaderValue::from_str(&format!("bytes {start}-{end}/{size}"))
.unwrap_or_else(|_| HeaderValue::from_static("bytes */0"))
}
pub(super) async fn verify(
State(state): State<Shared>,
Extension(ns): Extension<Namespace>,
Extension(permission): Extension<Permission>,
Json(id): Json<ObjectId>,
) -> Result<StatusCode, Error> {
permission.require_write()?;
if state.store.exists(&ns, &id.oid).await {
return Ok(StatusCode::OK);
}
let Some(arrived) = state.store.uploaded_size(&ns, &id.oid).await? else {
return Err(Error::NotFound);
};
if arrived != id.size {
return Err(Error::SizeMismatch {
declared: id.size,
actual: arrived,
});
}
if let Some(limit) = state
.config
.max_object_size
.filter(|limit| arrived > *limit)
{
return Err(Error::TooLarge { limit });
}
if let Some(limit) = state.config.repo_quota {
let (_, used) = state.store.usage_of(&ns).await;
let budget = Budget { used, limit };
if budget.exceeded_by(arrived) {
return Err(budget.refusal());
}
}
state.store.adopt(&ns, &id.oid, arrived).await?;
state.metrics.object_size.observe(arrived as f64);
Ok(StatusCode::OK)
}