//! Image upload and serving handlers. use axum::{ body::Body, extract::{Multipart, Path}, http::{StatusCode, header}, response::{IntoResponse, Response}, }; use mt_core::types::ModAction; use crate::auth::MaybeUser; use crate::storage; use crate::AppState; use super::{get_community, get_role, check_community_access, is_mod_or_owner}; /// Max uploads per user per hour. const UPLOAD_RATE_LIMIT: i64 = 20; const UPLOAD_RATE_WINDOW_SECS: i64 = 3600; /// POST /p/{slug}/upload — multipart image upload, returns JSON with markdown link. #[tracing::instrument(skip_all)] pub(super) async fn upload_image_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, mut multipart: Multipart, ) -> Result { let user = session_user .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; let s3 = state.s3.as_ref().ok_or_else(|| { (StatusCode::SERVICE_UNAVAILABLE, "Image uploads are not configured.").into_response() })?; let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, Some(user.user_id)).await?; // Check membership let role = get_role(&state.db, user.user_id, community.id).await?; if role.is_none() { return Err((StatusCode::FORBIDDEN, "You must be a community member to upload.").into_response()); } // Rate limit: uploads per hour let recent = mt_db::queries::count_recent_uploads_by_user( &state.db, user.user_id, UPLOAD_RATE_WINDOW_SECS, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error checking upload rate"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if recent >= UPLOAD_RATE_LIMIT { return Err((StatusCode::TOO_MANY_REQUESTS, "Upload limit reached. Try again later.").into_response()); } // Read the multipart field let field = multipart .next_field() .await .map_err(|e| { tracing::error!(error = ?e, "multipart read error"); (StatusCode::BAD_REQUEST, "Invalid upload.").into_response() })? .ok_or_else(|| (StatusCode::BAD_REQUEST, "No file provided.").into_response())?; let filename = field.file_name().unwrap_or("image").to_string(); let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); let data = field.bytes().await.map_err(|e| { tracing::error!(error = ?e, "failed to read upload bytes"); (StatusCode::BAD_REQUEST, "Failed to read file.").into_response() })?; let (ext, validated_ct) = storage::validate_image(&filename, &content_type, data.len()) .map_err(|msg| (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response())?; // Strip EXIF from JPEG let data = if ext == "jpg" { storage::strip_exif_jpeg(&data) } else { data.to_vec() }; let s3_key = storage::generate_image_key(&slug, ext); let data_len = data.len() as i64; // Upload to S3 s3.upload(&s3_key, validated_ct, data).await.map_err(|e| { tracing::error!(error = %e, "S3 upload failed"); (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed.").into_response() })?; // Record in DB let image_id = mt_db::mutations::insert_image( &state.db, user.user_id, community.id, &s3_key, &filename, validated_ct, data_len, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error inserting image"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Return JSON with the image URL for markdown insertion let url = format!("/uploads/{image_id}"); let markdown = format!("![{filename}]({url})"); Ok(axum::Json(serde_json::json!({ "url": url, "markdown": markdown, "id": image_id.to_string(), }))) } /// GET /uploads/{id} — serve an uploaded image (proxied from S3). #[tracing::instrument(skip_all)] pub(super) async fn serve_image_handler( axum::extract::State(state): axum::extract::State, Path(image_id_str): Path, ) -> Result { let image_id = super::parse_uuid(&image_id_str)?; // Check DB first — return 404 before checking S3 availability let image = mt_db::queries::get_image(&state.db, image_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching image"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let s3 = state.s3.as_ref().ok_or_else(|| { StatusCode::SERVICE_UNAVAILABLE.into_response() })?; // Don't serve removed images if image.removed_at.is_some() { return Err(StatusCode::GONE.into_response()); } let (data, content_type) = s3.download(&image.s3_key).await.map_err(|e| { tracing::error!(error = %e, "S3 download failed"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CACHE_CONTROL, "public, max-age=86400, immutable") .body(Body::from(data)) .unwrap()) } /// POST /p/{slug}/uploads/{id}/remove — mod removes an uploaded image. #[tracing::instrument(skip_all)] pub(super) async fn remove_image_handler( axum::extract::State(state): axum::extract::State, Path((slug, image_id_str)): Path<(String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; let community = get_community(&state.db, &slug).await?; let role = get_role(&state.db, user.user_id, community.id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } let image_id = super::parse_uuid(&image_id_str)?; mt_db::mutations::remove_image(&state.db, image_id, user.user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error removing image"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Log mod action super::log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::RemoveImage, None, Some(image_id), None, ) .await; Ok(StatusCode::OK) }