Skip to main content

max / makenotwork

13.6 KB · 365 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.
104 let mut data: Vec<u8> = Vec::new();
105 loop {
106 match field.chunk().await {
107 Ok(Some(chunk)) => {
108 if data.len() + chunk.len() > storage::MAX_IMAGE_SIZE {
109 return Err((
110 StatusCode::PAYLOAD_TOO_LARGE,
111 "Image exceeds the 5 MB limit.",
112 )
113 .into_response());
114 }
115 data.extend_from_slice(&chunk);
116 }
117 Ok(None) => break,
118 Err(e) => {
119 tracing::error!(error = ?e, "failed to read upload bytes");
120 return Err((StatusCode::BAD_REQUEST, "Failed to read file.").into_response());
121 }
122 }
123 }
124
125 let (ext, validated_ct) = storage::validate_image(&filename, &content_type, &data)
126 .map_err(|msg| (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response())?;
127
128 // Strip EXIF from JPEG
129 let data = if ext == "jpg" {
130 storage::strip_exif_jpeg(&data)
131 } else {
132 data
133 };
134
135 let s3_key = storage::generate_image_key(&slug, ext);
136 let data_len = data.len() as i64;
137
138 // Record the DB row BEFORE the S3 upload. If we uploaded first and the
139 // insert then failed, the S3 object would have no row and the reconcile
140 // sweep (keyed on `images`) could never find it, an unbounded orphan.
141 // Insert-first inverts the failure mode: a failed/absent upload leaves at
142 // most a row with no object, which is bounded, queryable, and cleaned up
143 // below (or by the sweep), never a silent S3 cost.
144 let image_id = mt_db::mutations::insert_image(
145 &state.db,
146 user.user_id,
147 community.id,
148 &s3_key,
149 &filename,
150 validated_ct,
151 data_len,
152 )
153 .await
154 .map_err(db_error)?;
155
156 // Upload to S3. On failure, drop the row we just inserted so neither store
157 // is left holding a dangling reference; if the cleanup delete itself fails,
158 // the row remains pointing at a missing object, recoverable, not a
159 // leaked S3 object.
160 if let Err(e) = s3.upload(&s3_key, validated_ct, data).await {
161 tracing::error!(error = %e, "S3 upload failed");
162 if let Err(del) = mt_db::mutations::delete_image_row(&state.db, image_id).await {
163 tracing::warn!(error = ?del, image_id = %image_id, "failed to roll back image row after S3 upload failure");
164 }
165 return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
166 }
167
168 // Return JSON with the image URL for markdown insertion
169 let url = format!("/uploads/{image_id}");
170 let markdown = format!("![{filename}]({url})");
171
172 Ok(axum::Json(serde_json::json!({
173 "url": url,
174 "markdown": markdown,
175 "id": image_id.to_string(),
176 })))
177 }
178
179 /// GET /uploads/{id}, serve an uploaded image (proxied from S3).
180 ///
181 /// Enforces the owning community's access policy: a suspended community or a
182 /// caller banned from it cannot read the bytes. The cache is `private` so the
183 /// access decision is never stored in a shared cache and replayed to an
184 /// unauthorized viewer.
185 #[tracing::instrument(skip_all)]
186 pub(super) async fn serve_image_handler(
187 axum::extract::State(state): axum::extract::State<AppState>,
188 MaybeUser(session_user): MaybeUser,
189 Path(image_id_str): Path<String>,
190 ) -> Result<Response, Response> {
191 let image_id = super::parse_uuid(&image_id_str)?;
192
193 // Check DB first, return 404 before checking S3 availability
194 let image = mt_db::queries::get_image(&state.db, image_id)
195 .await
196 .map_err(db_error)?
197 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
198
199 // Don't serve removed images
200 if image.removed_at.is_some() {
201 return Err(StatusCode::GONE.into_response());
202 }
203
204 // Enforce the owning community's access policy before serving any bytes.
205 let community = mt_db::queries::get_community_by_id(&state.db, image.community_id)
206 .await
207 .map_err(db_error)?
208 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
209 check_community_access(
210 &state.db,
211 &community,
212 session_user.as_ref().map(|u| u.user_id),
213 )
214 .await?;
215
216 let s3 = state
217 .s3
218 .as_ref()
219 .ok_or_else(|| StatusCode::SERVICE_UNAVAILABLE.into_response())?;
220
221 // Stream the object straight from S3 to the client instead of buffering the
222 // whole image into a Vec<u8> per request (ultra-fuzz S1). The Content-Type
223 // comes from the stored row (set from upload-time magic-byte validation), so
224 // we don't need S3's metadata round-trip.
225 let body = s3.download_stream(&image.s3_key).await.map_err(|e| {
226 tracing::error!(error = %e, "S3 stream open failed");
227 StatusCode::INTERNAL_SERVER_ERROR.into_response()
228 })?;
229
230 // Serve hardening. `nosniff` pins the browser to the stored Content-Type
231 // (which upload-time magic-byte validation guarantees is a real image), so
232 // a content-spoofed file can never be reinterpreted as HTML/JS. Disposition
233 // stays `inline` because these are embedded in post bodies via <img>;
234 // `attachment` would force a download and break the feature. The byte-level
235 // validation, not a disposition flag, is what makes inline serving safe.
236 Ok(Response::builder()
237 .status(StatusCode::OK)
238 .header(header::CONTENT_TYPE, image.content_type)
239 .header(header::CACHE_CONTROL, "private, max-age=86400, immutable")
240 .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
241 .header(header::CONTENT_DISPOSITION, "inline")
242 .body(body)
243 .unwrap())
244 }
245
246 /// POST /p/{slug}/uploads/{id}/remove, mod removes an uploaded image.
247 #[tracing::instrument(skip_all)]
248 pub(super) async fn remove_image_handler(
249 axum::extract::State(state): axum::extract::State<AppState>,
250 Path((slug, image_id_str)): Path<(String, String)>,
251 MaybeUser(session_user): MaybeUser,
252 ) -> Result<impl IntoResponse, Response> {
253 let user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
254
255 let community = get_community(&state.db, &slug).await?;
256 let role = get_role(&state.db, user.user_id, community.id).await?;
257
258 if !is_mod_or_owner(role) {
259 return Err(StatusCode::FORBIDDEN.into_response());
260 }
261
262 let image_id = super::parse_uuid(&image_id_str)?;
263
264 // Fetch first so we have the S3 key and can confirm the image belongs to
265 // the community whose mod is acting (authorize against the resource, not
266 // just the URL slug).
267 let image = mt_db::queries::get_image(&state.db, image_id)
268 .await
269 .map_err(db_error)?
270 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
271 if image.community_id != community.id {
272 return Err(StatusCode::NOT_FOUND.into_response());
273 }
274
275 // Mark the image removed and write the audit row on one transaction, so the
276 // removal can never land without its log entry.
277 let mut tx = super::begin_tx(&state.db).await?;
278 mt_db::mutations::remove_image(&mut *tx, image_id, user.user_id)
279 .await
280 .map_err(db_error)?;
281 super::audit(
282 &mut tx,
283 Some(community.id),
284 ModActor::User(user.user_id),
285 ModAction::RemoveImage,
286 None,
287 Some(image_id),
288 None,
289 )
290 .await?;
291 super::commit_tx(tx).await?;
292
293 // Delete the backing S3 object so removed images don't accumulate in the
294 // bucket forever. Best-effort, after the removal+log have committed: the DB
295 // row is already marked removed (serve returns 410). On success, record
296 // s3_purged_at so the reconcile sweep skips it; on failure, leave it unmarked
297 // so the sweep retries it later.
298 if let Some(s3) = state.s3.as_ref() {
299 match s3.delete(&image.s3_key).await {
300 Ok(()) => {
301 if let Err(e) =
302 mt_db::mutations::mark_images_s3_purged(&state.db, &[image_id]).await
303 {
304 tracing::warn!(error = ?e, "failed to mark image S3-purged (sweep will retry)");
305 }
306 }
307 Err(e) => {
308 tracing::warn!(error = %e, s3_key = %image.s3_key, "failed to delete removed image from S3 (sweep will retry)");
309 }
310 }
311 }
312
313 Ok(StatusCode::OK)
314 }
315
316 /// Query for [`image_proxy_handler`].
317 #[derive(Deserialize)]
318 pub(super) struct ImageProxyQuery {
319 /// The external image URL to fetch (percent-encoded by the renderer).
320 u: String,
321 }
322
323 /// GET /img-proxy?u=<url>, same-origin proxy for external Fan+ images.
324 ///
325 /// The Fan+ markdown renderer rewrites external `<img src="https://…">` to point
326 /// here so the page CSP can stay `img-src 'self'` (M-UX1). This handler fetches
327 /// the URL through the SSRF-safe link-preview client (private addresses refused
328 /// at connect time, even across redirects), caps the body at 1 MB / 5 s, and
329 /// re-serves only recognised image content-types.
330 ///
331 /// Login-gated so it can't be driven as an open image-fetch relay by anonymous
332 /// clients, and it sits in the per-IP image rate-limit group.
333 #[tracing::instrument(skip_all)]
334 pub(super) async fn image_proxy_handler(
335 axum::extract::State(state): axum::extract::State<AppState>,
336 MaybeUser(session_user): MaybeUser,
337 Query(query): Query<ImageProxyQuery>,
338 ) -> Result<Response, Response> {
339 // Authenticated members only, an anonymous open proxy is the abuse vector.
340 let _user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
341
342 let client = match &state.link_preview {
343 crate::link_preview::LinkPreviewFetcher::Http(c) => c,
344 crate::link_preview::LinkPreviewFetcher::Noop => {
345 return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
346 }
347 };
348
349 let (bytes, content_type) = crate::link_preview::fetch_image(client, &query.u)
350 .await
351 .ok_or_else(|| StatusCode::BAD_GATEWAY.into_response())?;
352
353 // `nosniff` pins the browser to the validated image content-type, so even if
354 // an upstream served image bytes under a benign type, it can't be reframed as
355 // HTML/JS. 1 MB cap means buffering here is bounded.
356 Ok(Response::builder()
357 .status(StatusCode::OK)
358 .header(header::CONTENT_TYPE, content_type)
359 .header(header::CACHE_CONTROL, "private, max-age=86400")
360 .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
361 .header(header::CONTENT_DISPOSITION, "inline")
362 .body(Body::from(bytes))
363 .unwrap())
364 }
365