Skip to content
This repository was archived by the owner on Aug 7, 2025. It is now read-only.

Commit 0a0da1d

Browse files
zeeroothaumetra
andauthored
Remove exif info from image uploads (#352)
* remove exif info during file uploads * add some space * formatting issue * add a comment about mime types --------- Co-authored-by: aumetra <[email protected]>
1 parent 1b79751 commit 0a0da1d

File tree

3 files changed

+208
-7
lines changed

3 files changed

+208
-7
lines changed

crates/kitsune-core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ garde = { version = "0.15.0", default-features = false, features = [
3434
globset = "0.4.13"
3535
hex-simd = "0.8.0"
3636
http = "0.2.9"
37+
img-parts = "0.3.0"
3738
iso8601-timestamp = "0.2.12"
3839
just-retry = { path = "../../lib/just-retry" }
3940
kitsune-cache = { path = "../kitsune-cache" }
@@ -90,4 +91,5 @@ kitsune-test = { path = "../kitsune-test" }
9091
pretty_assertions = "1.4.0"
9192
redis = "0.23.3"
9293
serial_test = "2.0.0"
94+
tempfile = "3.8.0"
9395
tower = "0.4.13"

crates/kitsune-core/src/error.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use std::error::Error as ErrorTrait;
2+
13
use kitsune_http_signatures::ring;
24
use thiserror::Error;
35
use tokio::sync::oneshot;
46

7+
pub type BoxError = Box<dyn ErrorTrait + Send + Sync>;
58
pub type Result<T, E = Error> = std::result::Result<T, E>;
69

710
#[derive(Debug, Error)]
@@ -46,6 +49,15 @@ pub enum FederationFilterError {
4649
UrlParse(#[from] url::ParseError),
4750
}
4851

52+
#[derive(Debug, Error)]
53+
pub enum UploadError {
54+
#[error(transparent)]
55+
ImageProcessingError(#[from] img_parts::Error),
56+
57+
#[error(transparent)]
58+
StreamError(#[from] BoxError),
59+
}
60+
4961
#[derive(Debug, Error)]
5062
pub enum Error {
5163
#[error(transparent)]
@@ -120,6 +132,9 @@ pub enum Error {
120132
#[error(transparent)]
121133
TokioOneshot(#[from] oneshot::error::RecvError),
122134

135+
#[error(transparent)]
136+
Upload(#[from] UploadError),
137+
123138
#[error(transparent)]
124139
UriInvalid(#[from] http::uri::InvalidUri),
125140

crates/kitsune-core/src/service/attachment.rs

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
use super::url::UrlService;
22
use crate::{
33
consts::{MAX_MEDIA_DESCRIPTION_LENGTH, USER_AGENT},
4-
error::{ApiError, Error, Result},
4+
error::{ApiError, Error, Result, UploadError},
55
};
6-
use bytes::Bytes;
6+
use bytes::{Bytes, BytesMut};
77
use derive_builder::Builder;
88
use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl};
99
use diesel_async::RunQueryDsl;
10-
use futures_util::{Stream, StreamExt, TryStreamExt};
10+
use futures_util::{pin_mut, stream, Stream, StreamExt, TryStreamExt};
1111
use garde::Validate;
12+
use img_parts::{DynImage, ImageEXIF};
1213
use kitsune_db::{
1314
model::media_attachment::{MediaAttachment, NewMediaAttachment, UpdateMediaAttachment},
1415
schema::media_attachments,
@@ -35,6 +36,11 @@ fn is_allowed_filetype(value: &str, _ctx: &()) -> garde::Result {
3536
Ok(())
3637
}
3738

39+
fn is_image_type_with_supported_metadata(mime: &str) -> bool {
40+
// TODO: migrate the match to use the mime crate enums
41+
matches!(mime, "image/jpeg" | "image/png" | "image/webp")
42+
}
43+
3844
#[derive(TypedBuilder, Validate)]
3945
pub struct Update {
4046
#[garde(skip)]
@@ -179,10 +185,38 @@ impl AttachmentService {
179185
{
180186
upload.validate(&())?;
181187

182-
self.storage_backend
183-
.put(&upload.path, upload.stream)
184-
.await
185-
.map_err(Error::Storage)?;
188+
// remove exif info from image uploads
189+
let upload_stream = if is_image_type_with_supported_metadata(&upload.content_type) {
190+
let stream = upload.stream;
191+
pin_mut!(stream);
192+
193+
let mut img_bytes = BytesMut::new();
194+
while let Some(chunk) = stream
195+
.next()
196+
.await
197+
.transpose()
198+
.map_err(UploadError::StreamError)?
199+
{
200+
img_bytes.extend_from_slice(&chunk);
201+
}
202+
203+
let img_bytes = img_bytes.freeze();
204+
let final_bytes = DynImage::from_bytes(img_bytes)
205+
.map_err(UploadError::ImageProcessingError)?
206+
.ok_or(img_parts::Error::WrongSignature)
207+
.map(|mut image| {
208+
image.set_exif(None);
209+
image.encoder().bytes()
210+
})
211+
.map_err(UploadError::ImageProcessingError)?;
212+
213+
self.storage_backend
214+
.put(&upload.path, stream::once(async { Ok(final_bytes) }))
215+
} else {
216+
self.storage_backend.put(&upload.path, upload.stream)
217+
};
218+
219+
upload_stream.await.map_err(Error::Storage)?;
186220

187221
let media_attachment = self
188222
.db_pool
@@ -205,3 +239,153 @@ impl AttachmentService {
205239
Ok(media_attachment)
206240
}
207241
}
242+
243+
#[cfg(test)]
244+
mod test {
245+
use std::convert::Infallible;
246+
247+
use bytes::{Bytes, BytesMut};
248+
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
249+
use futures_util::{future, pin_mut, stream, StreamExt};
250+
use http::{Request, Response};
251+
use hyper::Body;
252+
use img_parts::{
253+
jpeg::{markers, JpegSegment},
254+
ImageEXIF,
255+
};
256+
use iso8601_timestamp::Timestamp;
257+
use kitsune_db::{
258+
model::{
259+
account::{ActorType, NewAccount},
260+
media_attachment::MediaAttachment,
261+
},
262+
schema::accounts,
263+
};
264+
use kitsune_http_client::Client;
265+
use kitsune_storage::fs::Storage;
266+
use kitsune_test::database_test;
267+
use scoped_futures::ScopedFutureExt;
268+
use speedy_uuid::Uuid;
269+
use tempfile::TempDir;
270+
use tower::service_fn;
271+
272+
use crate::{
273+
error::Error,
274+
service::{
275+
attachment::{AttachmentService, Upload},
276+
url::UrlService,
277+
},
278+
};
279+
280+
#[tokio::test]
281+
#[serial_test::serial]
282+
async fn upload_jpeg() {
283+
database_test(|db_pool| async move {
284+
let client = Client::builder().service(service_fn(handle));
285+
286+
let account_id = db_pool
287+
.with_connection(|db_conn| {
288+
async move { Ok::<_, eyre::Report>(prepare_db(db_conn).await) }.scoped()
289+
})
290+
.await
291+
.unwrap();
292+
293+
let temp_dir = TempDir::new().unwrap();
294+
let storage = Storage::new(temp_dir.path().to_owned());
295+
let url_service = UrlService::builder()
296+
.domain("example.com")
297+
.scheme("http")
298+
.build();
299+
300+
let attachment_service = AttachmentService::builder()
301+
.client(client)
302+
.db_pool(db_pool)
303+
.url_service(url_service)
304+
.storage_backend(storage)
305+
.media_proxy_enabled(false)
306+
.build();
307+
308+
let base = hex_simd::decode_to_vec("ffd8ffe000104a46494600010101004800480000ffdb004300030202020202030202020303030304060404040404080606050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b080001000101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9").unwrap();
309+
let mut jpeg = img_parts::jpeg::Jpeg::from_bytes(Bytes::from(base)).unwrap();
310+
311+
let comment_segment = JpegSegment::new_with_contents(
312+
markers::APP1,
313+
Bytes::from("Exif\0\0Some info to be stripped")
314+
);
315+
jpeg.segments_mut().insert(1, comment_segment);
316+
assert!(jpeg.exif().is_some());
317+
318+
let upload = Upload::builder()
319+
.content_type(String::from("image/jpeg"))
320+
.path(String::from("test.jpeg"))
321+
.stream(stream::once(future::ok(jpeg.encoder().bytes())))
322+
.account_id(account_id).build().unwrap();
323+
attachment_service.upload(upload).await.unwrap();
324+
325+
let attachment = MediaAttachment {
326+
id: Uuid::now_v7(),
327+
account_id,
328+
content_type: String::from("image/jpeg"),
329+
description: None,
330+
blurhash: None,
331+
file_path: Some(String::from("test.jpeg")),
332+
remote_url: None,
333+
created_at: Timestamp::now_utc(),
334+
updated_at: Timestamp::now_utc()
335+
};
336+
let download = attachment_service.stream_file(&attachment).await.unwrap();
337+
338+
let mut img_bytes = BytesMut::new();
339+
pin_mut!(download);
340+
while let Some(chunk) = download.next().await.transpose().unwrap() {
341+
img_bytes.extend_from_slice(&chunk);
342+
}
343+
let img_bytes = img_bytes.freeze();
344+
345+
let jpeg = img_parts::jpeg::Jpeg::from_bytes(img_bytes).unwrap();
346+
assert!(jpeg.exif().is_none());
347+
})
348+
.await;
349+
}
350+
351+
async fn handle(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
352+
Ok::<_, Infallible>(Response::new(Body::from("")))
353+
}
354+
355+
async fn prepare_db(db_conn: &mut AsyncPgConnection) -> Uuid {
356+
// Create a local user `@alice`
357+
db_conn
358+
.transaction(|tx| {
359+
async move {
360+
let account_id = Uuid::now_v7();
361+
diesel::insert_into(accounts::table)
362+
.values(NewAccount {
363+
id: account_id,
364+
display_name: None,
365+
username: "alice",
366+
locked: false,
367+
note: None,
368+
local: true,
369+
domain: "example.com",
370+
actor_type: ActorType::Person,
371+
url: "https://example.com/users/alice",
372+
featured_collection_url: None,
373+
followers_url: None,
374+
following_url: None,
375+
inbox_url: None,
376+
outbox_url: None,
377+
shared_inbox_url: None,
378+
public_key_id: "https://example.com/users/alice#main-key",
379+
public_key: "",
380+
created_at: None,
381+
})
382+
.execute(tx)
383+
.await?;
384+
Ok::<_, Error>(account_id)
385+
}
386+
.scope_boxed()
387+
})
388+
.await
389+
.unwrap()
390+
}
391+
}

0 commit comments

Comments
 (0)