Skip to main content

max / multithreaded

6.5 KB · 201 lines History Blame Raw
1 //! Image upload and serving handlers.
2
3 use axum::{
4 body::Body,
5 extract::{Multipart, Path},
6 http::{StatusCode, header},
7 response::{IntoResponse, Response},
8 };
9 use mt_core::types::ModAction;
10
11 use crate::auth::MaybeUser;
12 use crate::storage;
13 use crate::AppState;
14
15 use super::{get_community, get_role, check_community_access, is_mod_or_owner};
16
17 /// Max uploads per user per hour.
18 const UPLOAD_RATE_LIMIT: i64 = 20;
19 const UPLOAD_RATE_WINDOW_SECS: i64 = 3600;
20
21 /// POST /p/{slug}/upload — multipart image upload, returns JSON with markdown link.
22 #[tracing::instrument(skip_all)]
23 pub(super) async fn upload_image_handler(
24 axum::extract::State(state): axum::extract::State<AppState>,
25 Path(slug): Path<String>,
26 MaybeUser(session_user): MaybeUser,
27 mut multipart: Multipart,
28 ) -> Result<impl IntoResponse, Response> {
29 let user = session_user
30 .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
31
32 let s3 = state.s3.as_ref().ok_or_else(|| {
33 (StatusCode::SERVICE_UNAVAILABLE, "Image uploads are not configured.").into_response()
34 })?;
35
36 let community = get_community(&state.db, &slug).await?;
37 check_community_access(&state.db, &community, Some(user.user_id)).await?;
38
39 // Check membership
40 let role = get_role(&state.db, user.user_id, community.id).await?;
41 if role.is_none() {
42 return Err((StatusCode::FORBIDDEN, "You must be a community member to upload.").into_response());
43 }
44
45 // Rate limit: uploads per hour
46 let recent = mt_db::queries::count_recent_uploads_by_user(
47 &state.db, user.user_id, UPLOAD_RATE_WINDOW_SECS,
48 )
49 .await
50 .map_err(|e| {
51 tracing::error!(error = ?e, "db error checking upload rate");
52 StatusCode::INTERNAL_SERVER_ERROR.into_response()
53 })?;
54 if recent >= UPLOAD_RATE_LIMIT {
55 return Err((StatusCode::TOO_MANY_REQUESTS, "Upload limit reached. Try again later.").into_response());
56 }
57
58 // Read the multipart field
59 let field = multipart
60 .next_field()
61 .await
62 .map_err(|e| {
63 tracing::error!(error = ?e, "multipart read error");
64 (StatusCode::BAD_REQUEST, "Invalid upload.").into_response()
65 })?
66 .ok_or_else(|| (StatusCode::BAD_REQUEST, "No file provided.").into_response())?;
67
68 let filename = field.file_name().unwrap_or("image").to_string();
69 let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
70
71 let data = field.bytes().await.map_err(|e| {
72 tracing::error!(error = ?e, "failed to read upload bytes");
73 (StatusCode::BAD_REQUEST, "Failed to read file.").into_response()
74 })?;
75
76 let (ext, validated_ct) = storage::validate_image(&filename, &content_type, data.len())
77 .map_err(|msg| (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response())?;
78
79 // Strip EXIF from JPEG
80 let data = if ext == "jpg" {
81 storage::strip_exif_jpeg(&data)
82 } else {
83 data.to_vec()
84 };
85
86 let s3_key = storage::generate_image_key(&slug, ext);
87 let data_len = data.len() as i64;
88
89 // Upload to S3
90 s3.upload(&s3_key, validated_ct, data).await.map_err(|e| {
91 tracing::error!(error = %e, "S3 upload failed");
92 (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed.").into_response()
93 })?;
94
95 // Record in DB
96 let image_id = mt_db::mutations::insert_image(
97 &state.db,
98 user.user_id,
99 community.id,
100 &s3_key,
101 &filename,
102 validated_ct,
103 data_len,
104 )
105 .await
106 .map_err(|e| {
107 tracing::error!(error = ?e, "db error inserting image");
108 StatusCode::INTERNAL_SERVER_ERROR.into_response()
109 })?;
110
111 // Return JSON with the image URL for markdown insertion
112 let url = format!("/uploads/{image_id}");
113 let markdown = format!("![{filename}]({url})");
114
115 Ok(axum::Json(serde_json::json!({
116 "url": url,
117 "markdown": markdown,
118 "id": image_id.to_string(),
119 })))
120 }
121
122 /// GET /uploads/{id} — serve an uploaded image (proxied from S3).
123 #[tracing::instrument(skip_all)]
124 pub(super) async fn serve_image_handler(
125 axum::extract::State(state): axum::extract::State<AppState>,
126 Path(image_id_str): Path<String>,
127 ) -> Result<Response, Response> {
128 let image_id = super::parse_uuid(&image_id_str)?;
129
130 // Check DB first — return 404 before checking S3 availability
131 let image = mt_db::queries::get_image(&state.db, image_id)
132 .await
133 .map_err(|e| {
134 tracing::error!(error = ?e, "db error fetching image");
135 StatusCode::INTERNAL_SERVER_ERROR.into_response()
136 })?
137 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
138
139 let s3 = state.s3.as_ref().ok_or_else(|| {
140 StatusCode::SERVICE_UNAVAILABLE.into_response()
141 })?;
142
143 // Don't serve removed images
144 if image.removed_at.is_some() {
145 return Err(StatusCode::GONE.into_response());
146 }
147
148 let (data, content_type) = s3.download(&image.s3_key).await.map_err(|e| {
149 tracing::error!(error = %e, "S3 download failed");
150 StatusCode::INTERNAL_SERVER_ERROR.into_response()
151 })?;
152
153 Ok(Response::builder()
154 .status(StatusCode::OK)
155 .header(header::CONTENT_TYPE, content_type)
156 .header(header::CACHE_CONTROL, "public, max-age=86400, immutable")
157 .body(Body::from(data))
158 .unwrap())
159 }
160
161 /// POST /p/{slug}/uploads/{id}/remove — mod removes an uploaded image.
162 #[tracing::instrument(skip_all)]
163 pub(super) async fn remove_image_handler(
164 axum::extract::State(state): axum::extract::State<AppState>,
165 Path((slug, image_id_str)): Path<(String, String)>,
166 MaybeUser(session_user): MaybeUser,
167 ) -> Result<impl IntoResponse, Response> {
168 let user = session_user
169 .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
170
171 let community = get_community(&state.db, &slug).await?;
172 let role = get_role(&state.db, user.user_id, community.id).await?;
173
174 if !is_mod_or_owner(&role) {
175 return Err(StatusCode::FORBIDDEN.into_response());
176 }
177
178 let image_id = super::parse_uuid(&image_id_str)?;
179
180 mt_db::mutations::remove_image(&state.db, image_id, user.user_id)
181 .await
182 .map_err(|e| {
183 tracing::error!(error = ?e, "db error removing image");
184 StatusCode::INTERNAL_SERVER_ERROR.into_response()
185 })?;
186
187 // Log mod action
188 super::log_mod_action(
189 &state.db,
190 Some(community.id),
191 user.user_id,
192 ModAction::RemoveImage,
193 None,
194 Some(image_id),
195 None,
196 )
197 .await;
198
199 Ok(StatusCode::OK)
200 }
201