use crate::indieauth::{backend::AuthBackend, User}; use axum::{ extract::{multipart::Multipart, FromRef, Path, State}, response::{IntoResponse, Response}, }; use axum_extra::extract::Host; use axum_extra::headers::{ContentLength, HeaderMapExt, HeaderValue, IfNoneMatch}; use axum_extra::TypedHeader; use kittybox_indieauth::Scope; use kittybox_util::micropub::{Error as MicropubError, ErrorKind as ErrorType}; pub mod storage; pub use storage::file::FileStore; use storage::{ErrorKind, MediaStore, MediaStoreError, Metadata}; impl From for MicropubError { fn from(err: MediaStoreError) -> Self { Self::new( ErrorType::InternalServerError, format!("media store error: {}", err), ) } } #[tracing::instrument(skip(blobstore))] pub(crate) async fn upload( State(blobstore): State, user: User, mut upload: Multipart, ) -> Response { if !user.check_scope(&Scope::Media) { return MicropubError::from_static( ErrorType::NotAuthorized, "Interacting with the media storage requires the \"media\" scope.", ) .into_response(); } let host = user.me.authority(); let field = match upload.next_field().await { Ok(Some(field)) => field, Ok(None) => { return MicropubError::from_static( ErrorType::InvalidRequest, "Send multipart/form-data with one field named file", ) .into_response(); } Err(err) => { return MicropubError::new( ErrorType::InternalServerError, 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( Host(host): Host, Path(path): Path, if_none_match: Option>, State(blobstore): State, ) -> 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::() .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(), ); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, axum::http::HeaderValue::from_static("nosniff"), ); if let Some(length) = metadata.length { headers.typed_insert(ContentLength(length.get().try_into().unwrap())); } if let Some(etag) = etag { headers.typed_insert(etag); } } r.body(axum::body::Body::from_stream(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) } }, } } pub fn router() -> axum::Router where A: AuthBackend + FromRef, M: MediaStore + FromRef, St: Clone + Send + Sync + 'static, { axum::Router::new() .route("/", axum::routing::post(upload::)) .route("/uploads/{*file}", axum::routing::get(serve::)) }