11use super :: url:: UrlService ;
22use 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 } ;
77use derive_builder:: Builder ;
88use diesel:: { BoolExpressionMethods , ExpressionMethods , QueryDsl } ;
99use diesel_async:: RunQueryDsl ;
10- use futures_util:: { Stream , StreamExt , TryStreamExt } ;
10+ use futures_util:: { pin_mut , stream , Stream , StreamExt , TryStreamExt } ;
1111use garde:: Validate ;
12+ use img_parts:: { DynImage , ImageEXIF } ;
1213use 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 ) ]
3945pub 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 \0 Some 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