use axum::{ extract::{multipart::Multipart, FromRef, Host, Path, State}, response::{IntoResponse, Response} }; use axum_extra::headers::{HeaderMapExt, HeaderValue, IfNoneMatch}; use axum_extra::TypedHeader; use kittybox_util::micropub::{Error as MicropubError, ErrorKind as ErrorType}; use kittybox_indieauth::Scope; use crate::indieauth::{backend::AuthBackend, User}; pub mod storage; use storage::{MediaStore, MediaStoreError, Metadata, ErrorKind}; pub use storage::file::FileStore; impl From for MicropubError { fn from(err: MediaStoreError) -> Self { Self { error: ErrorType::InternalServerError, error_description: format!("{}", 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 { 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( 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() ); 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::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::)) }