use axum::Router;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use futures::StreamExt;
use gfeh_core::{Meta, ObjectStore, OpCtx, OpenMode, Perm, RangeSpec, parse_range};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use crate::exposure::{Exposed, Exposures};
#[derive(Debug)]
pub struct HttpView {
address: SocketAddr,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
}
impl HttpView {
#[must_use]
pub fn builder(store: Arc<dyn ObjectStore>, exposures: Arc<dyn Exposures>) -> HttpBuilder {
HttpBuilder {
store,
exposures,
bind: SocketAddr::from(([127, 0, 0, 1], 0)),
}
}
#[must_use]
pub fn address(&self) -> SocketAddr {
self.address
}
#[must_use]
pub fn base_url(&self) -> String {
format!("http://{}", self.address)
}
#[must_use]
pub fn url_for(&self, token: &str) -> String {
format!("{}/f/{token}", self.base_url())
}
}
impl Drop for HttpView {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
}
}
pub struct HttpBuilder {
store: Arc<dyn ObjectStore>,
exposures: Arc<dyn Exposures>,
bind: SocketAddr,
}
impl HttpBuilder {
#[must_use]
pub fn bind(mut self, addr: SocketAddr) -> Self {
self.bind = addr;
self
}
pub async fn start(self) -> std::io::Result<HttpView> {
let listener = TcpListener::bind(self.bind).await?;
let address = listener.local_addr()?;
let (tx, rx) = tokio::sync::oneshot::channel();
let state = Arc::new(Served {
store: self.store,
exposures: self.exposures,
});
let app = Router::new()
.route("/f/{token}", get(serve).head(serve))
.with_state(state);
tokio::spawn(async move {
let served = axum::serve(listener, app).with_graceful_shutdown(async {
let _ = rx.await;
});
if let Err(e) = served.await {
tracing::error!(error = %e, "the http view stopped serving");
}
});
Ok(HttpView {
address,
shutdown: Some(tx),
})
}
}
struct Served {
store: Arc<dyn ObjectStore>,
exposures: Arc<dyn Exposures>,
}
fn link_context() -> OpCtx {
OpCtx {
principal: "public".into(),
on_behalf_of: None,
protocol: "http",
granted: Perm::READ | Perm::META_READ,
}
}
async fn serve(
State(state): State<Arc<Served>>,
Path(token): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(exposed) = state.exposures.resolve(&token) else {
return not_found();
};
if !exposed.enabled {
return not_found();
}
let cx = link_context();
let meta = match state.store.stat(&cx, &exposed.node).await {
Ok(meta) => meta,
Err(_) => return not_found(),
};
let etag = meta
.etag
.as_ref()
.map(|tag| format!("\"{tag}\""))
.unwrap_or_default();
if is_unmodified(&headers, &meta, &etag) {
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::NOT_MODIFIED;
apply_validators(response.headers_mut(), &meta, &etag);
return response;
}
let requested = parse_range(
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
meta.size,
);
if requested == RangeSpec::Unsatisfiable {
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
insert(
response.headers_mut(),
header::CONTENT_RANGE,
&format!("bytes */{}", meta.size),
);
return response;
}
let handle = match state.store.open(&cx, &exposed.node, OpenMode::Read).await {
Ok(handle) => handle,
Err(_) => return not_found(),
};
let body_range = requested.byte_range();
let stream = match handle.read_stream(body_range) {
Ok(stream) => stream,
Err(_) => return not_found(),
};
let (status, length) = match requested {
RangeSpec::Partial { start, end } => (StatusCode::PARTIAL_CONTENT, end - start + 1),
RangeSpec::Whole | RangeSpec::Unsatisfiable => (StatusCode::OK, meta.size),
};
let body = Body::from_stream(stream.map(move |chunk| {
let _keep_alive = &handle;
chunk.map_err(std::io::Error::other)
}));
let mut response = Response::new(body);
*response.status_mut() = status;
let out = response.headers_mut();
apply_validators(out, &meta, &etag);
insert(out, header::CONTENT_LENGTH, &length.to_string());
insert(
out,
header::CONTENT_TYPE,
meta.mime.as_deref().unwrap_or("application/octet-stream"),
);
insert(out, header::ACCEPT_RANGES, "bytes");
insert(
out,
header::CONTENT_DISPOSITION,
&disposition(&exposed, &meta),
);
if let RangeSpec::Partial { start, end } = requested {
insert(
out,
header::CONTENT_RANGE,
&format!("bytes {start}-{end}/{}", meta.size),
);
}
response
}
fn apply_validators(out: &mut HeaderMap, meta: &Meta, etag: &str) {
if !etag.is_empty() {
insert(out, header::ETAG, etag);
}
insert(
out,
header::LAST_MODIFIED,
&gfeh_core::http_date(meta.times.modified),
);
}
fn is_unmodified(headers: &HeaderMap, meta: &Meta, etag: &str) -> bool {
if let Some(candidate) = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
{
return !etag.is_empty() && matches_etag(candidate, etag);
}
headers
.get(header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
.and_then(gfeh_core::parse_http_date)
.is_some_and(|since| meta.times.modified.div_euclid(1_000) * 1_000 <= since)
}
fn matches_etag(candidate: &str, etag: &str) -> bool {
if candidate.trim() == "*" {
return true;
}
candidate.split(',').any(|each| {
let each = each.trim();
let each = each.strip_prefix("W/").unwrap_or(each);
each == etag
})
}
fn disposition(exposed: &Exposed, meta: &Meta) -> String {
let name = exposed.filename.as_deref().unwrap_or(meta.name.as_str());
let safe: String = name
.chars()
.filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n'))
.collect();
let safe = if safe.is_empty() {
"download".to_string()
} else {
safe
};
format!("inline; filename=\"{safe}\"")
}
fn not_found() -> Response {
(StatusCode::NOT_FOUND, "not found").into_response()
}
fn insert(out: &mut HeaderMap, name: header::HeaderName, value: &str) {
if let Ok(value) = HeaderValue::from_str(value) {
out.insert(name, value);
}
}