Skip to main content

tuitbot_server/routes/
media.rs

1//! Media upload and serving endpoints.
2
3use 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::error::ApiError;
14use crate::state::AppState;
15
16/// `POST /api/media/upload` — upload a media file.
17///
18/// Accepts multipart form data with a `file` field.
19/// Returns `{ id, path, media_type, size }`.
20pub async fn upload(
21    State(state): State<Arc<AppState>>,
22    mut multipart: Multipart,
23) -> Result<Json<Value>, ApiError> {
24    let field = multipart
25        .next_field()
26        .await
27        .map_err(|e| ApiError::BadRequest(format!("invalid multipart data: {e}")))?
28        .ok_or_else(|| ApiError::BadRequest("no file field in request".to_string()))?;
29
30    let filename = field.file_name().unwrap_or("upload.bin").to_string();
31    let content_type = field.content_type().map(|s| s.to_string());
32
33    let data = field
34        .bytes()
35        .await
36        .map_err(|e| ApiError::BadRequest(format!("failed to read file data: {e}")))?;
37
38    let media_type =
39        media::detect_media_type(&filename, content_type.as_deref()).ok_or_else(|| {
40            ApiError::BadRequest(
41                "unsupported media type; accepted: jpeg, png, webp, gif, mp4".to_string(),
42            )
43        })?;
44
45    // Validate size.
46    if data.len() as u64 > media_type.max_size() {
47        return Err(ApiError::BadRequest(format!(
48            "file size {}B exceeds maximum {}B for {}",
49            data.len(),
50            media_type.max_size(),
51            media_type.mime_type()
52        )));
53    }
54
55    let local = media::store_media(&state.data_dir, &data, &filename, media_type)
56        .await
57        .map_err(|e| ApiError::Internal(format!("failed to store media: {e}")))?;
58
59    Ok(Json(json!({
60        "path": local.path,
61        "media_type": media_type.mime_type(),
62        "size": local.size,
63    })))
64}
65
66/// Query params for serving media files.
67#[derive(Deserialize)]
68pub struct MediaFileQuery {
69    /// Path to the media file.
70    pub path: String,
71}
72
73/// `GET /api/media/file?path=...` — serve a local media file.
74pub async fn serve_file(
75    State(state): State<Arc<AppState>>,
76    Query(params): Query<MediaFileQuery>,
77) -> Result<Response, ApiError> {
78    // Path traversal protection.
79    if !media::is_safe_media_path(&params.path, &state.data_dir) {
80        return Err(ApiError::BadRequest("invalid media path".to_string()));
81    }
82
83    let data = media::read_media(&params.path)
84        .await
85        .map_err(|e| ApiError::NotFound(format!("media file not found: {e}")))?;
86
87    let content_type = media::detect_media_type(&params.path, None)
88        .map(|mt| mt.mime_type())
89        .unwrap_or("application/octet-stream");
90
91    Ok(([(header::CONTENT_TYPE, content_type)], data).into_response())
92}