feat: Add media serving functionality with optional metadata stripping
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1755,9 +1755,11 @@ dependencies = [
|
|||||||
"config",
|
"config",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"image",
|
||||||
"nom-exif",
|
"nom-exif",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{DefaultBodyLimit, Multipart, Path, Request, State},
|
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::{
|
||||||
|
StatusCode,
|
||||||
|
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
||||||
|
},
|
||||||
response::{IntoResponse, Json},
|
response::{IntoResponse, Json},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use libertas_core::{error::CoreError, schema::UploadMediaData};
|
use libertas_core::{
|
||||||
|
error::CoreError, media_utils::strip_metadata_from_bytes, schema::UploadMediaData,
|
||||||
|
};
|
||||||
use std::{io, path::PathBuf};
|
use std::{io, path::PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
@@ -17,7 +23,10 @@ use crate::{
|
|||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractors::query_options::ApiListMediaOptions,
|
extractors::query_options::ApiListMediaOptions,
|
||||||
middleware::auth::{OptionalUserId, UserId},
|
middleware::auth::{OptionalUserId, UserId},
|
||||||
schema::{MediaDetailsResponse, MediaResponse, PaginatedResponse, map_paginated_response},
|
schema::{
|
||||||
|
MediaDetailsResponse, MediaResponse, PaginatedResponse, ServeFileQuery,
|
||||||
|
map_paginated_response,
|
||||||
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,16 +80,39 @@ async fn get_media_file(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
OptionalUserId(user_id): OptionalUserId,
|
OptionalUserId(user_id): OptionalUserId,
|
||||||
Path(media_id): Path<Uuid>,
|
Path(media_id): Path<Uuid>,
|
||||||
|
Query(query): Query<ServeFileQuery>,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let storage_path = state
|
let media = state
|
||||||
.media_service
|
.media_service
|
||||||
.get_media_filepath(media_id, user_id)
|
.get_media_for_serving(media_id, user_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let full_path = PathBuf::from(&state.config.media_library_path).join(&storage_path);
|
let full_path = PathBuf::from(&state.config.media_library_path).join(&media.storage_path);
|
||||||
|
|
||||||
ServeFile::new(full_path)
|
match query.strip {
|
||||||
|
true => {
|
||||||
|
let file_bytes = fs::read(&full_path).await.map_err(|e| {
|
||||||
|
ApiError::from(CoreError::Io(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
format!("File not found at {}: {}", full_path.display(), e),
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let body = strip_metadata_from_bytes(file_bytes, &media.mime_type).await?;
|
||||||
|
let disposition = format!("inline; filename=\"{}\"", media.original_filename);
|
||||||
|
let response = (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(CONTENT_TYPE, media.mime_type),
|
||||||
|
(CONTENT_DISPOSITION, disposition),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
false => ServeFile::new(full_path)
|
||||||
.oneshot(request)
|
.oneshot(request)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -89,6 +121,8 @@ async fn get_media_file(
|
|||||||
format!("File not found: {}", e),
|
format!("File not found: {}", e),
|
||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
|
.map(|res| res.into_response()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_media_thumbnail(
|
async fn get_media_thumbnail(
|
||||||
|
|||||||
@@ -273,3 +273,9 @@ where
|
|||||||
has_prev_page: core_response.has_prev_page,
|
has_prev_page: core_response.has_prev_page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ServeFileQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub strip: bool,
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,20 @@ impl MediaService for MediaServiceImpl {
|
|||||||
.ok_or(CoreError::NotFound("Thumbnail for Media".to_string(), id))
|
.ok_or(CoreError::NotFound("Thumbnail for Media".to_string(), id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_media_for_serving(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<Media> {
|
||||||
|
self.auth_service
|
||||||
|
.check_permission(user_id, authz::Permission::ViewMedia(id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let media = self
|
||||||
|
.repo
|
||||||
|
.find_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(CoreError::NotFound("Media".to_string(), id))?;
|
||||||
|
|
||||||
|
Ok(media)
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()> {
|
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()> {
|
||||||
self.auth_service
|
self.auth_service
|
||||||
.check_permission(Some(user_id), authz::Permission::DeleteMedia(id))
|
.check_permission(Some(user_id), authz::Permission::DeleteMedia(id))
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||||||
nom-exif = { version = "2.5.4", features = ["serde", "async", "tokio"] }
|
nom-exif = { version = "2.5.4", features = ["serde", "async", "tokio"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
config = "0.15.19"
|
config = "0.15.19"
|
||||||
|
image = "0.25.8"
|
||||||
|
tokio = { version = "1.48.0", features = ["rt"] }
|
||||||
@@ -4,7 +4,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
|
use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
|
||||||
|
use image::{ImageFormat, load_from_memory};
|
||||||
use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, MediaParser, MediaSource, TrackInfo};
|
use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, MediaParser, MediaSource, TrackInfo};
|
||||||
|
use tokio::task;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{CoreError, CoreResult},
|
error::{CoreError, CoreResult},
|
||||||
@@ -190,3 +192,34 @@ pub fn is_invalid_exif_tag(tag_name: &str, tag_value: &str) -> bool {
|
|||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn strip_metadata_from_bytes(
|
||||||
|
file_bytes: Vec<u8>,
|
||||||
|
mime_type: &str,
|
||||||
|
) -> CoreResult<Vec<u8>> {
|
||||||
|
if mime_type != "image/jpeg" && mime_type != "image/png" {
|
||||||
|
return Ok(file_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = mime_type.to_string();
|
||||||
|
|
||||||
|
task::spawn_blocking(move || -> CoreResult<Vec<u8>> {
|
||||||
|
let img = load_from_memory(&file_bytes)
|
||||||
|
.map_err(|e| CoreError::Unknown(format!("Failed to parse image: {}", e)))?;
|
||||||
|
|
||||||
|
let mut buffer = std::io::Cursor::new(Vec::new());
|
||||||
|
|
||||||
|
match mime_type.as_str() {
|
||||||
|
"image/jpeg" => img
|
||||||
|
.write_to(&mut buffer, ImageFormat::Jpeg)
|
||||||
|
.map_err(|e| CoreError::Unknown(format!("Failed to re-encode JPEG: {}", e)))?,
|
||||||
|
"image/png" => img
|
||||||
|
.write_to(&mut buffer, ImageFormat::Png)
|
||||||
|
.map_err(|e| CoreError::Unknown(format!("Failed to re-encode PNG: {}", e)))?,
|
||||||
|
_ => unreachable!(), // We already checked this
|
||||||
|
}
|
||||||
|
Ok(buffer.into_inner())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::Unknown(format!("Blocking task failed: {}", e)))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub trait MediaService: Send + Sync {
|
|||||||
async fn get_media_filepath(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<String>;
|
async fn get_media_filepath(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<String>;
|
||||||
async fn get_media_thumbnail_path(&self, id: Uuid, user_id: Option<Uuid>)
|
async fn get_media_thumbnail_path(&self, id: Uuid, user_id: Option<Uuid>)
|
||||||
-> CoreResult<String>;
|
-> CoreResult<String>;
|
||||||
|
async fn get_media_for_serving(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<Media>;
|
||||||
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>;
|
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user