use std::convert::TryFrom;
use axum::{
extract::{Extension, Host, multipart::Multipart, Path},
response::{IntoResponse, Response},
headers::{Header, HeaderValue, IfNoneMatch, HeaderMapExt},
TypedHeader,
};
use kittybox_util::error::{MicropubError, ErrorType};
use kittybox_indieauth::Scope;
use crate::indieauth::{User, backend::AuthBackend};
pub mod storage;
use storage::{MediaStore, MediaStoreError, Metadata, ErrorKind};
pub use storage::file::FileStore;
impl From<MediaStoreError> for MicropubError {
fn from(err: MediaStoreError) -> Self {
Self {
error: ErrorType::InternalServerError,
error_description: format!("{}", err)
}
}
}
#[tracing::instrument(skip(blobstore))]
pub(crate) async fn upload<S: MediaStore, A: AuthBackend>(
Extension(blobstore): Extension<S>,
user: User<A>,
mut upload: Multipart
) -> Response {
if !user.check_scope(&Scope::Media) {
return MicropubError {
error: ErrorType::NotAuthorized,
error_description: "Interacting with the media storage requires the \"media\" scope.".to_owned()
}.into_response();
}
let host = user.me.host().unwrap().to_string() + &user.me.port().map(|i| format!(":{}", i)).unwrap_or_default();
let field = match upload.next_field().await {
Ok(Some(field)) => field,
Ok(None) => {
return MicropubError {
error: ErrorType::InvalidRequest,
error_description: "Send multipart/form-data with one field named file".to_owned()
}.into_response();
},
Err(err) => {
return MicropubError {
error: ErrorType::InternalServerError,
error_description: format!("Error while parsing multipart/form-data: {}", err)
}.into_response();
},
};
let metadata: Metadata = (&field).into();
match blobstore.write_streaming(&host, metadata, field).await {
Ok(filename) => IntoResponse::into_response((
axum::http::StatusCode::CREATED,
[
("Location", user.me.join(
&format!(".kittybox/media/uploads/{}", filename)
).unwrap().as_str())
]
)),
Err(err) => MicropubError::from(err).into_response()
}
}
#[tracing::instrument(skip(blobstore))]
pub(crate) async fn serve<S: MediaStore>(
Host(host): Host,
Path(path): Path<String>,
if_none_match: Option<TypedHeader<IfNoneMatch>>,
Extension(blobstore): Extension<S>
) -> Response {
use axum::http::StatusCode;
tracing::debug!("Searching for file...");
match blobstore.read_streaming(&host, path.as_str()).await {
Ok((metadata, stream)) => {
tracing::debug!("Metadata: {:?}", metadata);
let etag = if let Some(etag) = metadata.etag {
let etag = format!("\"{}\"", etag).parse::<axum::headers::ETag>().unwrap();
if let Some(TypedHeader(if_none_match)) = if_none_match {
tracing::debug!("If-None-Match: {:?}", if_none_match);
// If-None-Match is a negative precondition that
// returns 304 when it doesn't match because it
// only matches when file is different
if !if_none_match.precondition_passes(&etag) {
return StatusCode::NOT_MODIFIED.into_response()
}
}
Some(etag)
} else { None };
let mut r = Response::builder();
{
let headers = r.headers_mut().unwrap();
headers.insert(
"Content-Type",
HeaderValue::from_str(
metadata.content_type
.as_deref()
.unwrap_or("application/octet-stream")
).unwrap()
);
if let Some(length) = metadata.length {
headers.insert(
"Content-Length",
HeaderValue::from_str(&length.to_string()).unwrap()
);
}
if let Some(etag) = etag {
headers.typed_insert(etag);
}
}
r.body(axum::body::StreamBody::new(stream))
.unwrap()
.into_response()
},
Err(err) => match err.kind() {
ErrorKind::NotFound => {
IntoResponse::into_response(StatusCode::NOT_FOUND)
},
_ => {
tracing::error!("{}", err);
IntoResponse::into_response(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
#[must_use]
pub fn router<S: MediaStore, A: AuthBackend>(blobstore: S, auth: A) -> axum::Router {
axum::Router::new()
.route("/", axum::routing::post(upload::<S, A>))
.route("/uploads/*file", axum::routing::get(serve::<S>))
.layer(axum::Extension(blobstore))
.layer(axum::Extension(auth))
}