pinakes/crates/pinakes-server/src/routes/upload.rs
NotAShelf 2b2c1830a1
pinakes-server: fix api key timing, notification scoping, and validate progress inputs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ieb342b4b48034de0a2184cdf89d068316a6a6964
2026-03-08 00:43:27 +03:00

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()))
}