about summary refs log tree commit diff
path: root/src/media/mod.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
committerVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
commit0617663b249f9ca488e5de652108b17d67fbaf45 (patch)
tree11564b6c8fa37bf9203a0a4cc1c4e9cc088cb1a5 /src/media/mod.rs
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
Moved the entire Kittybox tree into the root
Diffstat (limited to 'src/media/mod.rs')
-rw-r--r--src/media/mod.rs141
1 files changed, 141 insertions, 0 deletions
diff --git a/src/media/mod.rs b/src/media/mod.rs
new file mode 100644
index 0000000..71f875e
--- /dev/null
+++ b/src/media/mod.rs
@@ -0,0 +1,141 @@
+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))
+}