Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ieb342b4b48034de0a2184cdf89d068316a6a6964
190 lines
4.9 KiB
Rust
190 lines
4.9 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Multipart, Path, State},
|
|
http::{StatusCode, header},
|
|
response::IntoResponse,
|
|
};
|
|
use pinakes_core::{model::MediaId, upload};
|
|
use tokio_util::io::ReaderStream;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
dto::{ManagedStorageStatsResponse, UploadResponse},
|
|
error::{ApiError, ApiResult},
|
|
state::AppState,
|
|
};
|
|
|
|
/// Sanitize a filename for use in Content-Disposition headers.
|
|
/// Strips characters that could break header parsing or enable injection.
|
|
fn sanitize_content_disposition(filename: &str) -> String {
|
|
let safe: String = filename
|
|
.chars()
|
|
.map(|c| {
|
|
if c == '"' || c == '\\' || c == '\n' || c == '\r' {
|
|
'_'
|
|
} else {
|
|
c
|
|
}
|
|
})
|
|
.collect();
|
|
format!("attachment; filename=\"{safe}\"")
|
|
}
|
|
|
|
/// Upload a file to managed storage
|
|
/// POST /api/upload
|
|
pub async fn upload_file(
|
|
State(state): State<AppState>,
|
|
mut multipart: Multipart,
|
|
) -> ApiResult<Json<UploadResponse>> {
|
|
let managed_storage = state
|
|
.managed_storage
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?;
|
|
|
|
let config = state.config.read().await;
|
|
if !config.managed_storage.enabled {
|
|
return Err(ApiError::bad_request("Managed storage is not enabled"));
|
|
}
|
|
drop(config);
|
|
|
|
// Extract file from multipart
|
|
let field = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::bad_request(format!("Failed to read multipart field: {e}"))
|
|
})?
|
|
.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
|
|
|
let original_filename = field
|
|
.file_name()
|
|
.map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
|
|
|
|
let content_type = field.content_type().map_or_else(
|
|
|| "application/octet-stream".to_string(),
|
|
std::string::ToString::to_string,
|
|
);
|
|
|
|
let data = field.bytes().await.map_err(|e| {
|
|
ApiError::bad_request(format!("Failed to read file data: {e}"))
|
|
})?;
|
|
|
|
// Process the upload
|
|
let result = upload::process_upload_bytes(
|
|
&state.storage,
|
|
managed_storage.as_ref(),
|
|
&data,
|
|
&original_filename,
|
|
Some(&content_type),
|
|
)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Upload failed: {e}")))?;
|
|
|
|
Ok(Json(result.into()))
|
|
}
|
|
|
|
/// Download a managed file
|
|
/// GET /api/media/{id}/download
|
|
pub async fn download_file(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<impl IntoResponse> {
|
|
let media_id = MediaId(id);
|
|
let item = state
|
|
.storage
|
|
.get_media(media_id)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
|
|
|
let managed_storage = state
|
|
.managed_storage
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?;
|
|
|
|
// Check if this is a managed file
|
|
if item.storage_mode != pinakes_core::model::StorageMode::Managed {
|
|
// For external files, stream from their original path
|
|
let file = tokio::fs::File::open(&item.path)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("File not found: {e}")))?;
|
|
|
|
let stream = ReaderStream::new(file);
|
|
let body = axum::body::Body::from_stream(stream);
|
|
|
|
let content_type = item.media_type.mime_type();
|
|
|
|
let filename = item.original_filename.unwrap_or(item.file_name);
|
|
|
|
return Ok((
|
|
[
|
|
(header::CONTENT_TYPE, content_type),
|
|
(
|
|
header::CONTENT_DISPOSITION,
|
|
sanitize_content_disposition(&filename),
|
|
),
|
|
],
|
|
body,
|
|
));
|
|
}
|
|
|
|
// For managed files, stream from content-addressable storage
|
|
let file = managed_storage
|
|
.open(&item.content_hash)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Blob not found: {e}")))?;
|
|
|
|
let stream = ReaderStream::new(file);
|
|
let body = axum::body::Body::from_stream(stream);
|
|
|
|
let content_type = item.media_type.mime_type();
|
|
|
|
let filename = item.original_filename.unwrap_or(item.file_name);
|
|
|
|
Ok((
|
|
[
|
|
(header::CONTENT_TYPE, content_type),
|
|
(
|
|
header::CONTENT_DISPOSITION,
|
|
sanitize_content_disposition(&filename),
|
|
),
|
|
],
|
|
body,
|
|
))
|
|
}
|
|
|
|
/// Migrate an external file to managed storage
|
|
/// POST /api/media/{id}/move-to-managed
|
|
pub async fn move_to_managed(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<StatusCode> {
|
|
let managed_storage = state
|
|
.managed_storage
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?;
|
|
|
|
let media_id = MediaId(id);
|
|
upload::migrate_to_managed(
|
|
&state.storage,
|
|
managed_storage.as_ref(),
|
|
media_id,
|
|
)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Migration failed: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Get managed storage statistics
|
|
/// GET /api/managed/stats
|
|
pub async fn managed_stats(
|
|
State(state): State<AppState>,
|
|
) -> ApiResult<Json<ManagedStorageStatsResponse>> {
|
|
let stats = state
|
|
.storage
|
|
.managed_storage_stats()
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to get stats: {e}")))?;
|
|
|
|
Ok(Json(stats.into()))
|
|
}
|