tuitbot_server/routes/
media.rs1use std::sync::Arc;
4
5use axum::extract::{Multipart, Query, State};
6use axum::http::header;
7use axum::response::{IntoResponse, Response};
8use axum::Json;
9use serde::Deserialize;
10use serde_json::{json, Value};
11use tuitbot_core::storage::media;
12
13use crate::account::{require_mutate, AccountContext};
14use crate::error::ApiError;
15use crate::state::AppState;
16
17pub async fn upload(
22 State(state): State<Arc<AppState>>,
23 ctx: AccountContext,
24 mut multipart: Multipart,
25) -> Result<Json<Value>, ApiError> {
26 require_mutate(&ctx)?;
27 let field = multipart
28 .next_field()
29 .await
30 .map_err(|e| ApiError::BadRequest(format!("invalid multipart data: {e}")))?
31 .ok_or_else(|| ApiError::BadRequest("no file field in request".to_string()))?;
32
33 let filename = field.file_name().unwrap_or("upload.bin").to_string();
34 let content_type = field.content_type().map(|s| s.to_string());
35
36 let data = field
37 .bytes()
38 .await
39 .map_err(|e| ApiError::BadRequest(format!("failed to read file data: {e}")))?;
40
41 let media_type =
42 media::detect_media_type(&filename, content_type.as_deref()).ok_or_else(|| {
43 ApiError::BadRequest(
44 "unsupported media type; accepted: jpeg, png, webp, gif, mp4".to_string(),
45 )
46 })?;
47
48 if data.len() as u64 > media_type.max_size() {
50 return Err(ApiError::BadRequest(format!(
51 "file size {}B exceeds maximum {}B for {}",
52 data.len(),
53 media_type.max_size(),
54 media_type.mime_type()
55 )));
56 }
57
58 let local = media::store_media(&state.data_dir, &data, &filename, media_type)
59 .await
60 .map_err(|e| ApiError::Internal(format!("failed to store media: {e}")))?;
61
62 let data_dir = state.data_dir.clone();
64 let db = state.db.clone();
65 tokio::spawn(async move {
66 if let Err(e) = media::cleanup_if_over_threshold(&data_dir, &db).await {
67 tracing::warn!(error = %e, "Media cleanup failed");
68 }
69 });
70
71 Ok(Json(json!({
72 "path": local.path,
73 "media_type": media_type.mime_type(),
74 "size": local.size,
75 })))
76}
77
78#[derive(Deserialize)]
80pub struct MediaFileQuery {
81 pub path: String,
83}
84
85pub async fn serve_file(
87 State(state): State<Arc<AppState>>,
88 _ctx: AccountContext,
89 Query(params): Query<MediaFileQuery>,
90) -> Result<Response, ApiError> {
91 if !media::is_safe_media_path(¶ms.path, &state.data_dir) {
93 return Err(ApiError::BadRequest("invalid media path".to_string()));
94 }
95
96 let data = media::read_media(¶ms.path)
97 .await
98 .map_err(|e| ApiError::NotFound(format!("media file not found: {e}")))?;
99
100 let content_type = media::detect_media_type(¶ms.path, None)
101 .map(|mt| mt.mime_type())
102 .unwrap_or("application/octet-stream");
103
104 Ok(([(header::CONTENT_TYPE, content_type)], data).into_response())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn media_file_query_deserializes() {
113 let json = r#"{"path": "/data/media/image.png"}"#;
114 let q: MediaFileQuery = serde_json::from_str(json).expect("deser");
115 assert_eq!(q.path, "/data/media/image.png");
116 }
117}