Skip to main content

max / makenotwork

13.6 KB · 366 lines History Blame Raw
1 //! Image upload and serving handlers.
2
3 use axum::{
4 body::Body,
5 extract::{Multipart, Path, Query},
6 http::{StatusCode, header},
7 response::{IntoResponse, Response},
8 };
9 use serde::Deserialize;
10
11 use mt_core::types::{ModAction, ModActor};
12
13 use crate::AppState;
14 use crate::auth::MaybeUser;
15 use crate::storage;
16
17 use super::{
18 check_community_access, check_write_access, db_error, get_community, get_role, is_mod_or_owner,
19 };
20
21 /// Max uploads per user per hour.
22 const UPLOAD_RATE_LIMIT: i64 = 20;
23 const UPLOAD_RATE_WINDOW_SECS: i64 = 3600;
24
25 /// POST /p/{slug}/upload, multipart image upload, returns JSON with markdown link.
26 #[tracing::instrument(skip_all)]
27 pub(super) async fn upload_image_handler(
28 axum::extract::State(state): axum::extract::State<AppState>,
29 Path(slug): Path<String>,
30 MaybeUser(session_user): MaybeUser,
31 mut multipart: Multipart,
32 ) -> Result<impl IntoResponse, Response> {
33 let user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
34
35 let s3 = state.s3.as_ref().ok_or_else(|| {
36 (
37 StatusCode::SERVICE_UNAVAILABLE,
38 "Image uploads are not configured.",
39 )
40 .into_response()
41 })?;
42
43 let community = get_community(&state.db, &slug).await?;
44 // Uploading is a write: it stores an object, spends the caller's rate-limit
45 // budget, and yields a hosted URL to embed in a post. Same gate as posting,
46 // so a platform-suspended or muted user cannot stage images they are not
47 // allowed to publish.
48 check_write_access(
49 &state.db,
50 community.id,
51 user.user_id,
52 community.suspended_at.is_some(),
53 )
54 .await?;
55
56 // Check membership
57 let role = get_role(&state.db, user.user_id, community.id).await?;
58 if role.is_none() {
59 return Err((
60 StatusCode::FORBIDDEN,
61 "You must be a community member to upload.",
62 )
63 .into_response());
64 }
65
66 // Rate limit: uploads per hour
67 let recent = mt_db::queries::count_recent_uploads_by_user(
68 &state.db,
69 user.user_id,
70 UPLOAD_RATE_WINDOW_SECS,
71 )
72 .await
73 .map_err(db_error)?;
74 if recent >= UPLOAD_RATE_LIMIT {
75 return Err((
76 StatusCode::TOO_MANY_REQUESTS,
77 "Upload limit reached. Try again later.",
78 )
79 .into_response());
80 }
81
82 // Read the multipart field
83 let mut field = multipart
84 .next_field()
85 .await
86 .map_err(|e| {
87 tracing::error!(error = ?e, "multipart read error");
88 (StatusCode::BAD_REQUEST, "Invalid upload.").into_response()
89 })?
90 .ok_or_else(|| (StatusCode::BAD_REQUEST, "No file provided.").into_response())?;
91
92 let filename = field.file_name().unwrap_or("image").to_string();
93 let content_type = field
94 .content_type()
95 .unwrap_or("application/octet-stream")
96 .to_string();
97
98 // Read the field chunk-by-chunk, bailing the instant we cross MAX_IMAGE_SIZE
99 // rather than buffering the whole field first (`field.bytes()`) and checking
100 // afterwards. The `/p/{slug}/upload` route already carries a `DefaultBodyLimit`
101 // (routes/mod.rs), but that is a separately-configured layer that could drift;
102 // enforcing the same cap here keeps the handler self-defending against a
103 // memory-DoS regardless of the routing setup. See audit_review.md (uploads
104 // buffer-before-cap).
105 let mut data: Vec<u8> = Vec::new();
106 loop {
107 match field.chunk().await {
108 Ok(Some(chunk)) => {
109 if data.len() + chunk.len() > storage::MAX_IMAGE_SIZE {
110 return Err((
111 StatusCode::PAYLOAD_TOO_LARGE,
112 "Image exceeds the 5 MB limit.",
113 )
114 .into_response());
115 }
116 data.extend_from_slice(&chunk);
117 }
118 Ok(None) => break,
119 Err(e) => {
120 tracing::error!(error = ?e, "failed to read upload bytes");
121 return Err((StatusCode::BAD_REQUEST, "Failed to read file.").into_response());
122 }
123 }
124 }
125
126 let (ext, validated_ct) = storage::validate_image(&filename, &content_type, &data)
127 .map_err(|msg| (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response())?;
128
129 // Strip EXIF from JPEG
130 let data = if ext == "jpg" {
131 storage::strip_exif_jpeg(&data)
132 } else {
133 data
134 };
135
136 let s3_key = storage::generate_image_key(&slug, ext);
137 let data_len = data.len() as i64;
138
139 // Record the DB row BEFORE the S3 upload. If we uploaded first and the
140 // insert then failed, the S3 object would have no row and the reconcile
141 // sweep (keyed on `images`) could never find it, an unbounded orphan.
142 // Insert-first inverts the failure mode: a failed/absent upload leaves at
143 // most a row with no object, which is bounded, queryable, and cleaned up
144 // below (or by the sweep), never a silent S3 cost.
145 let image_id = mt_db::mutations::insert_image(
146 &state.db,
147 user.user_id,
148 community.id,
149 &s3_key,
150 &filename,
151 validated_ct,
152 data_len,
153 )
154 .await
155 .map_err(db_error)?;
156
157 // Upload to S3. On failure, drop the row we just inserted so neither store
158 // is left holding a dangling reference; if the cleanup delete itself fails,
159 // the row remains pointing at a missing object, recoverable, not a
160 // leaked S3 object.
161 if let Err(e) = s3.upload(&s3_key, validated_ct, data).await {
162 tracing::error!(error = %e, "S3 upload failed");
163 if let Err(del) = mt_db::mutations::delete_image_row(&state.db, image_id).await {
164 tracing::warn!(error = ?del, image_id = %image_id, "failed to roll back image row after S3 upload failure");
165 }
166 return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
167 }
168
169 // Return JSON with the image URL for markdown insertion
170 let url = format!("/uploads/{image_id}");
171 let markdown = format!("![{filename}]({url})");
172
173 Ok(axum::Json(serde_json::json!({
174 "url": url,
175 "markdown": markdown,
176 "id": image_id.to_string(),
177 })))
178 }
179
180 /// GET /uploads/{id}, serve an uploaded image (proxied from S3).
181 ///
182 /// Enforces the owning community's access policy: a suspended community or a
183 /// caller banned from it cannot read the bytes. The cache is `private` so the
184 /// access decision is never stored in a shared cache and replayed to an
185 /// unauthorized viewer.
186 #[tracing::instrument(skip_all)]
187 pub(super) async fn serve_image_handler(
188 axum::extract::State(state): axum::extract::State<AppState>,
189 MaybeUser(session_user): MaybeUser,
190 Path(image_id_str): Path<String>,
191 ) -> Result<Response, Response> {
192 let image_id = super::parse_uuid(&image_id_str)?;
193
194 // Check DB first, return 404 before checking S3 availability
195 let image = mt_db::queries::get_image(&state.db, image_id)
196 .await
197 .map_err(db_error)?
198 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
199
200 // Don't serve removed images
201 if image.removed_at.is_some() {
202 return Err(StatusCode::GONE.into_response());
203 }
204
205 // Enforce the owning community's access policy before serving any bytes.
206 let community = mt_db::queries::get_community_by_id(&state.db, image.community_id)
207 .await
208 .map_err(db_error)?
209 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
210 check_community_access(
211 &state.db,
212 &community,
213 session_user.as_ref().map(|u| u.user_id),
214 )
215 .await?;
216
217 let s3 = state
218 .s3
219 .as_ref()
220 .ok_or_else(|| StatusCode::SERVICE_UNAVAILABLE.into_response())?;
221
222 // Stream the object straight from S3 to the client instead of buffering the
223 // whole image into a Vec<u8> per request (ultra-fuzz S1). The Content-Type
224 // comes from the stored row (set from upload-time magic-byte validation), so
225 // we don't need S3's metadata round-trip.
226 let body = s3.download_stream(&image.s3_key).await.map_err(|e| {
227 tracing::error!(error = %e, "S3 stream open failed");
228 StatusCode::INTERNAL_SERVER_ERROR.into_response()
229 })?;
230
231 // Serve hardening. `nosniff` pins the browser to the stored Content-Type
232 // (which upload-time magic-byte validation guarantees is a real image), so
233 // a content-spoofed file can never be reinterpreted as HTML/JS. Disposition
234 // stays `inline` because these are embedded in post bodies via <img>;
235 // `attachment` would force a download and break the feature. The byte-level
236 // validation, not a disposition flag, is what makes inline serving safe.
237 Ok(Response::builder()
238 .status(StatusCode::OK)
239 .header(header::CONTENT_TYPE, image.content_type)
240 .header(header::CACHE_CONTROL, "private, max-age=86400, immutable")
241 .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
242 .header(header::CONTENT_DISPOSITION, "inline")
243 .body(body)
244 .unwrap())
245 }
246
247 /// POST /p/{slug}/uploads/{id}/remove, mod removes an uploaded image.
248 #[tracing::instrument(skip_all)]
249 pub(super) async fn remove_image_handler(
250 axum::extract::State(state): axum::extract::State<AppState>,
251 Path((slug, image_id_str)): Path<(String, String)>,
252 MaybeUser(session_user): MaybeUser,
253 ) -> Result<impl IntoResponse, Response> {
254 let user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
255
256 let community = get_community(&state.db, &slug).await?;
257 let role = get_role(&state.db, user.user_id, community.id).await?;
258
259 if !is_mod_or_owner(role) {
260 return Err(StatusCode::FORBIDDEN.into_response());
261 }
262
263 let image_id = super::parse_uuid(&image_id_str)?;
264
265 // Fetch first so we have the S3 key and can confirm the image belongs to
266 // the community whose mod is acting (authorize against the resource, not
267 // just the URL slug).
268 let image = mt_db::queries::get_image(&state.db, image_id)
269 .await
270 .map_err(db_error)?
271 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
272 if image.community_id != community.id {
273 return Err(StatusCode::NOT_FOUND.into_response());
274 }
275
276 // Mark the image removed and write the audit row on one transaction, so the
277 // removal can never land without its log entry.
278 let mut tx = super::begin_tx(&state.db).await?;
279 mt_db::mutations::remove_image(&mut *tx, image_id, user.user_id)
280 .await
281 .map_err(db_error)?;
282 super::audit(
283 &mut tx,
284 Some(community.id),
285 ModActor::User(user.user_id),
286 ModAction::RemoveImage,
287 None,
288 Some(image_id),
289 None,
290 )
291 .await?;
292 super::commit_tx(tx).await?;
293
294 // Delete the backing S3 object so removed images don't accumulate in the
295 // bucket forever. Best-effort, after the removal+log have committed: the DB
296 // row is already marked removed (serve returns 410). On success, record
297 // s3_purged_at so the reconcile sweep skips it; on failure, leave it unmarked
298 // so the sweep retries it later.
299 if let Some(s3) = state.s3.as_ref() {
300 match s3.delete(&image.s3_key).await {
301 Ok(()) => {
302 if let Err(e) =
303 mt_db::mutations::mark_images_s3_purged(&state.db, &[image_id]).await
304 {
305 tracing::warn!(error = ?e, "failed to mark image S3-purged (sweep will retry)");
306 }
307 }
308 Err(e) => {
309 tracing::warn!(error = %e, s3_key = %image.s3_key, "failed to delete removed image from S3 (sweep will retry)");
310 }
311 }
312 }
313
314 Ok(StatusCode::OK)
315 }
316
317 /// Query for [`image_proxy_handler`].
318 #[derive(Deserialize)]
319 pub(super) struct ImageProxyQuery {
320 /// The external image URL to fetch (percent-encoded by the renderer).
321 u: String,
322 }
323
324 /// GET /img-proxy?u=<url>, same-origin proxy for external Fan+ images.
325 ///
326 /// The Fan+ markdown renderer rewrites external `<img src="https://…">` to point
327 /// here so the page CSP can stay `img-src 'self'` (M-UX1). This handler fetches
328 /// the URL through the SSRF-safe link-preview client (private addresses refused
329 /// at connect time, even across redirects), caps the body at 1 MB / 5 s, and
330 /// re-serves only recognised image content-types.
331 ///
332 /// Login-gated so it can't be driven as an open image-fetch relay by anonymous
333 /// clients, and it sits in the per-IP image rate-limit group.
334 #[tracing::instrument(skip_all)]
335 pub(super) async fn image_proxy_handler(
336 axum::extract::State(state): axum::extract::State<AppState>,
337 MaybeUser(session_user): MaybeUser,
338 Query(query): Query<ImageProxyQuery>,
339 ) -> Result<Response, Response> {
340 // Authenticated members only, an anonymous open proxy is the abuse vector.
341 let _user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
342
343 let client = match &state.link_preview {
344 crate::link_preview::LinkPreviewFetcher::Http(c) => c,
345 crate::link_preview::LinkPreviewFetcher::Noop => {
346 return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
347 }
348 };
349
350 let (bytes, content_type) = crate::link_preview::fetch_image(client, &query.u)
351 .await
352 .ok_or_else(|| StatusCode::BAD_GATEWAY.into_response())?;
353
354 // `nosniff` pins the browser to the validated image content-type, so even if
355 // an upstream served image bytes under a benign type, it can't be reframed as
356 // HTML/JS. 1 MB cap means buffering here is bounded.
357 Ok(Response::builder()
358 .status(StatusCode::OK)
359 .header(header::CONTENT_TYPE, content_type)
360 .header(header::CACHE_CONTROL, "private, max-age=86400")
361 .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
362 .header(header::CONTENT_DISPOSITION, "inline")
363 .body(Body::from(bytes))
364 .unwrap())
365 }
366