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::account::{require_mutate, AccountContext};
14use crate::error::ApiError;
15use crate::state::AppState;
16
17/// `POST /api/media/upload` — upload a media file.
18///
19/// Accepts multipart form data with a `file` field.
20/// Returns `{ id, path, media_type, size }`.
21pub 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    // Validate size.
49    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    Ok(Json(json!({
63        "path": local.path,
64        "media_type": media_type.mime_type(),
65        "size": local.size,
66    })))
67}
68
69/// Query params for serving media files.
70#[derive(Deserialize)]
71pub struct MediaFileQuery {
72    /// Path to the media file.
73    pub path: String,
74}
75
76/// `GET /api/media/file?path=...` — serve a local media file.
77pub async fn serve_file(
78    State(state): State<Arc<AppState>>,
79    _ctx: AccountContext,
80    Query(params): Query<MediaFileQuery>,
81) -> Result<Response, ApiError> {
82    // Path traversal protection.
83    if !media::is_safe_media_path(&params.path, &state.data_dir) {
84        return Err(ApiError::BadRequest("invalid media path".to_string()));
85    }
86
87    let data = media::read_media(&params.path)
88        .await
89        .map_err(|e| ApiError::NotFound(format!("media file not found: {e}")))?;
90
91    let content_type = media::detect_media_type(&params.path, None)
92        .map(|mt| mt.mime_type())
93        .unwrap_or("application/octet-stream");
94
95    Ok(([(header::CONTENT_TYPE, content_type)], data).into_response())
96}