diff --git a/Cargo.lock b/Cargo.lock index f5fa565556..33fa44a7a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,9 +178,13 @@ name = "affine_server_native" version = "1.0.0" dependencies = [ "affine_common", + "anyhow", "chrono", "file-format", + "image", "infer", + "libwebp-sys", + "little_exif", "llm_adapter", "mimalloc", "mp4parse", @@ -235,6 +239,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -487,9 +506,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", @@ -497,9 +516,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", @@ -672,6 +691,27 @@ version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "473976d7a8620bb1e06dcdd184407c2363fe4fec8e983ee03ed9197222634a31" +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.1" @@ -707,6 +747,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -944,6 +990,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1608,6 +1660,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "file-format" version = "0.28.0" @@ -1887,6 +1948,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -2328,6 +2399,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + [[package]] name = "include-flate" version = "0.3.1" @@ -2675,6 +2774,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375ca3fbd6d89769361c5d505c9da676eb4128ee471b9fd763144d377a2d30e6" +dependencies = [ + "cc", + "glob", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2687,6 +2797,20 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "little_exif" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122" +dependencies = [ + "brotli", + "crc", + "log", + "miniz_oxide", + "paste", + "quick-xml", +] + [[package]] name = "llm_adapter" version = "0.1.1" @@ -2899,6 +3023,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mp4parse" version = "0.17.0" @@ -3317,6 +3451,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "path-ext" version = "0.1.2" @@ -3563,6 +3703,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "pom" version = "1.1.0" @@ -3704,12 +3857,33 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4216,7 +4390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -6771,3 +6945,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 4f435f3212..da26cba572 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,19 @@ resolver = "3" dotenvy = "0.15" file-format = { version = "0.28", features = ["reader"] } homedir = "0.3" + image = { version = "0.25.9", default-features = false, features = [ + "bmp", + "gif", + "jpeg", + "png", + "webp", + ] } infer = { version = "0.19.0" } lasso = { version = "0.7", features = ["multi-threaded"] } lib0 = { version = "0.16", features = ["lib0-serde"] } libc = "0.2" + libwebp-sys = "0.14.2" + little_exif = "0.6.23" llm_adapter = "0.1.1" log = "0.4" loom = { version = "0.7", features = ["checkpoint"] } diff --git a/packages/backend/native/Cargo.toml b/packages/backend/native/Cargo.toml index e286123bd8..caaddf88c6 100644 --- a/packages/backend/native/Cargo.toml +++ b/packages/backend/native/Cargo.toml @@ -14,9 +14,13 @@ affine_common = { workspace = true, features = [ "napi", "ydoc-loader", ] } +anyhow = { workspace = true } chrono = { workspace = true } file-format = { workspace = true } +image = { workspace = true } infer = { workspace = true } +libwebp-sys = { workspace = true } +little_exif = { workspace = true } llm_adapter = { workspace = true } mp4parse = { workspace = true } napi = { workspace = true, features = ["async"] } diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 30f16a4a73..8e08b09456 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -112,6 +112,8 @@ export declare function parsePageDoc(docBin: Buffer, maxSummaryLength?: number | export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocContent | null +export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise + export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array /** diff --git a/packages/backend/native/src/image.rs b/packages/backend/native/src/image.rs new file mode 100644 index 0000000000..5909a62f42 --- /dev/null +++ b/packages/backend/native/src/image.rs @@ -0,0 +1,353 @@ +use std::io::Cursor; + +use anyhow::{Context, Result as AnyResult, bail}; +use image::{ + AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader, + codecs::{gif::GifDecoder, png::PngDecoder, webp::WebPDecoder}, + imageops::FilterType, + metadata::Orientation, +}; +use libwebp_sys::{ + WEBP_MUX_ABI_VERSION, WebPData, WebPDataClear, WebPDataInit, WebPEncodeRGBA, WebPFree, WebPMuxAssemble, + WebPMuxCreateInternal, WebPMuxDelete, WebPMuxError, WebPMuxSetChunk, +}; +use little_exif::{exif_tag::ExifTag, filetype::FileExtension, metadata::Metadata}; +use napi::{ + Env, Error, Result, Status, Task, + bindgen_prelude::{AsyncTask, Buffer}, +}; +use napi_derive::napi; + +const WEBP_QUALITY: f32 = 80.0; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_IMAGE_PIXELS: u64 = 40_000_000; + +pub struct AsyncProcessImageTask { + input: Vec, + max_edge: u32, + keep_exif: bool, +} + +#[napi] +impl Task for AsyncProcessImageTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> Result { + process_image_inner(&self.input, self.max_edge, self.keep_exif) + .map_err(|error| Error::new(Status::InvalidArg, error.to_string())) + } + + fn resolve(&mut self, _: Env, output: Self::Output) -> Result { + Ok(output.into()) + } +} + +#[napi] +pub fn process_image(input: Buffer, max_edge: u32, keep_exif: bool) -> AsyncTask { + AsyncTask::new(AsyncProcessImageTask { + input: input.to_vec(), + max_edge, + keep_exif, + }) +} + +fn process_image_inner(input: &[u8], max_edge: u32, keep_exif: bool) -> AnyResult> { + if max_edge == 0 { + bail!("max_edge must be greater than 0"); + } + + let format = image::guess_format(input).context("unsupported image format")?; + let (width, height) = read_dimensions(input, format)?; + validate_dimensions(width, height)?; + let mut image = decode_image(input, format)?; + let orientation = read_orientation(input, format)?; + image.apply_orientation(orientation); + + if image.width().max(image.height()) > max_edge { + image = image.resize(max_edge, max_edge, FilterType::Lanczos3); + } + + let mut output = encode_webp_lossy(&image.into_rgba8())?; + + if keep_exif { + preserve_exif(input, format, &mut output)?; + } + + Ok(output) +} + +fn read_dimensions(input: &[u8], format: ImageFormat) -> AnyResult<(u32, u32)> { + ImageReader::with_format(Cursor::new(input), format) + .into_dimensions() + .context("failed to decode image") +} + +fn validate_dimensions(width: u32, height: u32) -> AnyResult<()> { + if width == 0 || height == 0 { + bail!("failed to decode image"); + } + + if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION { + bail!("image dimensions exceed limit"); + } + + if u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS { + bail!("image pixel count exceeds limit"); + } + + Ok(()) +} + +fn decode_image(input: &[u8], format: ImageFormat) -> AnyResult { + Ok(match format { + ImageFormat::Gif => { + let decoder = GifDecoder::new(Cursor::new(input)).context("failed to decode image")?; + let frame = decoder + .into_frames() + .next() + .transpose() + .context("failed to decode image")? + .context("image does not contain any frames")?; + DynamicImage::ImageRgba8(frame.into_buffer()) + } + ImageFormat::Png => { + let decoder = PngDecoder::new(Cursor::new(input)).context("failed to decode image")?; + if decoder.is_apng().context("failed to decode image")? { + let frame = decoder + .apng() + .context("failed to decode image")? + .into_frames() + .next() + .transpose() + .context("failed to decode image")? + .context("image does not contain any frames")?; + DynamicImage::ImageRgba8(frame.into_buffer()) + } else { + DynamicImage::from_decoder(decoder).context("failed to decode image")? + } + } + ImageFormat::WebP => { + let decoder = WebPDecoder::new(Cursor::new(input)).context("failed to decode image")?; + let frame = decoder + .into_frames() + .next() + .transpose() + .context("failed to decode image")? + .context("image does not contain any frames")?; + DynamicImage::ImageRgba8(frame.into_buffer()) + } + _ => { + let reader = ImageReader::with_format(Cursor::new(input), format); + let decoder = reader.into_decoder().context("failed to decode image")?; + DynamicImage::from_decoder(decoder).context("failed to decode image")? + } + }) +} + +fn read_orientation(input: &[u8], format: ImageFormat) -> AnyResult { + Ok(match format { + ImageFormat::Gif => GifDecoder::new(Cursor::new(input)) + .context("failed to decode image")? + .orientation() + .context("failed to decode image")?, + ImageFormat::Png => PngDecoder::new(Cursor::new(input)) + .context("failed to decode image")? + .orientation() + .context("failed to decode image")?, + ImageFormat::WebP => WebPDecoder::new(Cursor::new(input)) + .context("failed to decode image")? + .orientation() + .context("failed to decode image")?, + _ => ImageReader::with_format(Cursor::new(input), format) + .into_decoder() + .context("failed to decode image")? + .orientation() + .context("failed to decode image")?, + }) +} + +fn encode_webp_lossy(image: &image::RgbaImage) -> AnyResult> { + let width = i32::try_from(image.width()).context("image width is too large")?; + let height = i32::try_from(image.height()).context("image height is too large")?; + let stride = width.checked_mul(4).context("image width is too large")?; + + let mut output = std::ptr::null_mut(); + let encoded_len = unsafe { WebPEncodeRGBA(image.as_ptr(), width, height, stride, WEBP_QUALITY, &mut output) }; + + if output.is_null() || encoded_len == 0 { + bail!("failed to encode webp"); + } + + let encoded = unsafe { std::slice::from_raw_parts(output, encoded_len) }.to_vec(); + unsafe { + WebPFree(output.cast()); + } + + Ok(encoded) +} + +fn preserve_exif(input: &[u8], format: ImageFormat, output: &mut Vec) -> AnyResult<()> { + let Some(file_type) = map_exif_file_type(format) else { + return Ok(()); + }; + + let input = input.to_vec(); + let Ok(mut metadata) = Metadata::new_from_vec(&input, file_type) else { + return Ok(()); + }; + + metadata.remove_tag(ExifTag::Orientation(vec![1])); + + if !metadata.get_ifds().iter().any(|ifd| !ifd.get_tags().is_empty()) { + return Ok(()); + } + + let encoded_metadata = metadata.encode().context("failed to preserve exif metadata")?; + let source = WebPData { + bytes: output.as_ptr(), + size: output.len(), + }; + let exif = WebPData { + bytes: encoded_metadata.as_ptr(), + size: encoded_metadata.len(), + }; + let mut assembled = WebPData::default(); + let mux = unsafe { WebPMuxCreateInternal(&source, 1, WEBP_MUX_ABI_VERSION as _) }; + if mux.is_null() { + bail!("failed to preserve exif metadata"); + } + + let encoded = (|| -> AnyResult> { + if unsafe { WebPMuxSetChunk(mux, c"EXIF".as_ptr(), &exif, 1) } != WebPMuxError::WEBP_MUX_OK { + bail!("failed to preserve exif metadata"); + } + + WebPDataInit(&mut assembled); + + if unsafe { WebPMuxAssemble(mux, &mut assembled) } != WebPMuxError::WEBP_MUX_OK { + bail!("failed to preserve exif metadata"); + } + + Ok(unsafe { std::slice::from_raw_parts(assembled.bytes, assembled.size) }.to_vec()) + })(); + + unsafe { + WebPDataClear(&mut assembled); + WebPMuxDelete(mux); + } + + *output = encoded?; + + Ok(()) +} + +fn map_exif_file_type(format: ImageFormat) -> Option { + match format { + ImageFormat::Jpeg => Some(FileExtension::JPEG), + ImageFormat::Png => Some(FileExtension::PNG { as_zTXt_chunk: true }), + ImageFormat::Tiff => Some(FileExtension::TIFF), + ImageFormat::WebP => Some(FileExtension::WEBP), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use image::{ExtendedColorType, GenericImageView, ImageEncoder, codecs::png::PngEncoder}; + + use super::*; + + fn encode_png(width: u32, height: u32) -> Vec { + let image = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 0, 255])); + let mut encoded = Vec::new(); + PngEncoder::new(&mut encoded) + .write_image(image.as_raw(), width, height, ExtendedColorType::Rgba8) + .unwrap(); + encoded + } + + fn encode_bmp_header(width: u32, height: u32) -> Vec { + let mut encoded = Vec::with_capacity(54); + encoded.extend_from_slice(b"BM"); + encoded.extend_from_slice(&(54u32).to_le_bytes()); + encoded.extend_from_slice(&0u16.to_le_bytes()); + encoded.extend_from_slice(&0u16.to_le_bytes()); + encoded.extend_from_slice(&(54u32).to_le_bytes()); + encoded.extend_from_slice(&(40u32).to_le_bytes()); + encoded.extend_from_slice(&(width as i32).to_le_bytes()); + encoded.extend_from_slice(&(height as i32).to_le_bytes()); + encoded.extend_from_slice(&1u16.to_le_bytes()); + encoded.extend_from_slice(&24u16.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded.extend_from_slice(&0u32.to_le_bytes()); + encoded + } + + #[test] + fn process_image_keeps_small_dimensions() { + let png = encode_png(8, 6); + let output = process_image_inner(&png, 512, false).unwrap(); + + let format = image::guess_format(&output).unwrap(); + assert_eq!(format, ImageFormat::WebP); + + let decoded = image::load_from_memory(&output).unwrap(); + assert_eq!(decoded.dimensions(), (8, 6)); + } + + #[test] + fn process_image_scales_down_large_dimensions() { + let png = encode_png(1024, 256); + let output = process_image_inner(&png, 512, false).unwrap(); + let decoded = image::load_from_memory(&output).unwrap(); + + assert_eq!(decoded.dimensions(), (512, 128)); + } + + #[test] + fn process_image_preserves_exif_without_orientation() { + let png = encode_png(8, 8); + let mut png_with_exif = png.clone(); + let mut metadata = Metadata::new(); + metadata.set_tag(ExifTag::ImageDescription("copilot".to_string())); + metadata.set_tag(ExifTag::Orientation(vec![6])); + metadata + .write_to_vec(&mut png_with_exif, FileExtension::PNG { as_zTXt_chunk: true }) + .unwrap(); + + let output = process_image_inner(&png_with_exif, 512, true).unwrap(); + let decoded_metadata = Metadata::new_from_vec(&output, FileExtension::WEBP).unwrap(); + + assert!( + decoded_metadata + .get_tag(&ExifTag::ImageDescription(String::new())) + .next() + .is_some() + ); + assert!( + decoded_metadata + .get_tag(&ExifTag::Orientation(vec![1])) + .next() + .is_none() + ); + } + + #[test] + fn process_image_rejects_invalid_input() { + let error = process_image_inner(b"not-an-image", 512, false).unwrap_err(); + assert_eq!(error.to_string(), "unsupported image format"); + } + + #[test] + fn process_image_rejects_images_over_dimension_limit_before_decode() { + let bmp = encode_bmp_header(MAX_IMAGE_DIMENSION + 1, 1); + let error = process_image_inner(&bmp, 512, false).unwrap_err(); + + assert_eq!(error.to_string(), "image dimensions exceed limit"); + } +} diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index 0332c53750..988ef8fdb8 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -7,6 +7,7 @@ pub mod doc_loader; pub mod file_type; pub mod hashcash; pub mod html_sanitize; +pub mod image; pub mod llm; pub mod tiktoken; diff --git a/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts index 1014d3cb80..82a1316a3a 100644 --- a/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot/copilot.e2e.ts @@ -16,6 +16,7 @@ import { CopilotEmbeddingJob, MockEmbeddingClient, } from '../../plugins/copilot/embedding'; +import { ChatMessageCache } from '../../plugins/copilot/message'; import { prompts, PromptService } from '../../plugins/copilot/prompt'; import { CopilotProviderFactory, @@ -416,6 +417,7 @@ test('should be able to use test provider', async t => { test('should create message correctly', async t => { const { app } = t.context; + const messageCache = app.get(ChatMessageCache); { const { id } = await createWorkspace(app); @@ -463,6 +465,19 @@ test('should create message correctly', async t => { new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' }) ); t.truthy(messageId, 'should be able to create message with blob'); + + const message = await messageCache.get(messageId); + const attachment = message?.attachments?.[0] as + | { attachment: string; mimeType: string } + | undefined; + const payload = Buffer.from( + attachment?.attachment.split(',').at(1) || '', + 'base64' + ); + + t.is(attachment?.mimeType, 'image/webp'); + t.is(payload.subarray(0, 4).toString('ascii'), 'RIFF'); + t.is(payload.subarray(8, 12).toString('ascii'), 'WEBP'); } // with attachments diff --git a/packages/backend/server/src/__tests__/user/user.e2e.ts b/packages/backend/server/src/__tests__/user/user.e2e.ts index c6490d3d53..8e31c9840a 100644 --- a/packages/backend/server/src/__tests__/user/user.e2e.ts +++ b/packages/backend/server/src/__tests__/user/user.e2e.ts @@ -4,9 +4,9 @@ import type { TestFn } from 'ava'; import ava from 'ava'; import { + createBmp, createTestingApp, getPublicUserById, - smallestGif, smallestPng, TestingApp, updateAvatar, @@ -40,7 +40,10 @@ test('should be able to upload user avatar', async t => { const avatarRes = await app.GET(new URL(avatarUrl).pathname); - t.deepEqual(avatarRes.body, avatar); + t.true(avatarRes.headers['content-type'].startsWith('image/webp')); + t.notDeepEqual(avatarRes.body, avatar); + t.is(avatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF'); + t.is(avatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP'); }); test('should be able to update user avatar, and invalidate old avatar url', async t => { @@ -54,9 +57,7 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl; - const newAvatar = await fetch(smallestGif) - .then(res => res.arrayBuffer()) - .then(b => Buffer.from(b)); + const newAvatar = createBmp(32, 32); res = await updateAvatar(app, newAvatar); const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl; @@ -66,7 +67,46 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn t.is(avatarRes.status, 404); const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname); - t.deepEqual(newAvatarRes.body, newAvatar); + t.true(newAvatarRes.headers['content-type'].startsWith('image/webp')); + t.notDeepEqual(newAvatarRes.body, newAvatar); + t.is(newAvatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF'); + t.is(newAvatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP'); +}); + +test('should accept avatar uploads up to 5MB after conversion', async t => { + const { app } = t.context; + + await app.signup(); + const avatar = createBmp(1024, 1024); + t.true(avatar.length > 500 * 1024); + t.true(avatar.length < 5 * 1024 * 1024); + + const res = await updateAvatar(app, avatar, { + filename: 'large.bmp', + contentType: 'image/bmp', + }); + + t.is(res.status, 200); + const avatarUrl = res.body.data.uploadAvatar.avatarUrl; + const avatarRes = await app.GET(new URL(avatarUrl).pathname); + + t.true(avatarRes.headers['content-type'].startsWith('image/webp')); +}); + +test('should reject unsupported vector avatars', async t => { + const { app } = t.context; + + await app.signup(); + const avatar = Buffer.from( + '' + ); + const res = await updateAvatar(app, avatar, { + filename: 'avatar.svg', + contentType: 'image/svg+xml', + }); + + t.is(res.status, 200); + t.is(res.body.errors[0].message, 'Image format not supported: image/svg+xml'); }); test('should be able to get public user by id', async t => { diff --git a/packages/backend/server/src/__tests__/utils/blobs.ts b/packages/backend/server/src/__tests__/utils/blobs.ts index d4e2f71029..ccca87d614 100644 --- a/packages/backend/server/src/__tests__/utils/blobs.ts +++ b/packages/backend/server/src/__tests__/utils/blobs.ts @@ -7,6 +7,35 @@ export const smallestPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'; export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='; +export function createBmp(width: number, height: number) { + const rowSize = Math.ceil((width * 3) / 4) * 4; + const pixelDataSize = rowSize * height; + const fileSize = 54 + pixelDataSize; + const buffer = Buffer.alloc(fileSize); + + buffer.write('BM', 0, 'ascii'); + buffer.writeUInt32LE(fileSize, 2); + buffer.writeUInt32LE(54, 10); + buffer.writeUInt32LE(40, 14); + buffer.writeInt32LE(width, 18); + buffer.writeInt32LE(height, 22); + buffer.writeUInt16LE(1, 26); + buffer.writeUInt16LE(24, 28); + buffer.writeUInt32LE(pixelDataSize, 34); + + for (let y = 0; y < height; y++) { + const rowOffset = 54 + y * rowSize; + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + x * 3; + buffer[pixelOffset] = 0x33; + buffer[pixelOffset + 1] = 0x66; + buffer[pixelOffset + 2] = 0x99; + } + } + + return buffer; +} + export async function listBlobs( app: TestingApp, workspaceId: string diff --git a/packages/backend/server/src/__tests__/utils/user.ts b/packages/backend/server/src/__tests__/utils/user.ts index 28811584bc..bb06d1dc04 100644 --- a/packages/backend/server/src/__tests__/utils/user.ts +++ b/packages/backend/server/src/__tests__/utils/user.ts @@ -121,7 +121,11 @@ export async function deleteAccount(app: TestingApp) { return res.deleteAccount.success; } -export async function updateAvatar(app: TestingApp, avatar: Buffer) { +export async function updateAvatar( + app: TestingApp, + avatar: Buffer, + options: { filename?: string; contentType?: string } = {} +) { return app .POST('/graphql') .field( @@ -138,7 +142,7 @@ export async function updateAvatar(app: TestingApp, avatar: Buffer) { ) .field('map', JSON.stringify({ '0': ['variables.avatar'] })) .attach('0', avatar, { - filename: 'test.png', - contentType: 'image/png', + filename: options.filename || 'test.png', + contentType: options.contentType || 'image/png', }); } diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 768eaa7884..be7682d564 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -301,6 +301,11 @@ export const USER_FRIENDLY_ERRORS = { }, // Input errors + image_format_not_supported: { + type: 'invalid_input', + args: { format: 'string' }, + message: ({ format }) => `Image format not supported: ${format}`, + }, query_too_long: { type: 'invalid_input', args: { max: 'number' }, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index afbcb63df4..497b92c4ec 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -82,6 +82,16 @@ export class EmailServiceNotConfigured extends UserFriendlyError { } } @ObjectType() +class ImageFormatNotSupportedDataType { + @Field() format!: string +} + +export class ImageFormatNotSupported extends UserFriendlyError { + constructor(args: ImageFormatNotSupportedDataType, message?: string | ((args: ImageFormatNotSupportedDataType) => string)) { + super('invalid_input', 'image_format_not_supported', message, args); + } +} +@ObjectType() class QueryTooLongDataType { @Field() max!: number } @@ -1155,6 +1165,7 @@ export enum ErrorNames { SSRF_BLOCKED_ERROR, RESPONSE_TOO_LARGE_ERROR, EMAIL_SERVICE_NOT_CONFIGURED, + IMAGE_FORMAT_NOT_SUPPORTED, QUERY_TOO_LONG, VALIDATION_ERROR, USER_NOT_FOUND, @@ -1297,5 +1308,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, + [GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, ImageFormatNotSupportedDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const, }); diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 565be2b4c2..275db70f32 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es'; import { CannotDeleteOwnAccount, type FileUpload, + ImageFormatNotSupported, + OneMB, readBufferWithLimit, sniffMime, Throttle, @@ -28,6 +30,7 @@ import { UserFeatureName, UserSettingsSchema, } from '../../models'; +import { processImage } from '../../native'; import { Public } from '../auth/guard'; import { sessionUser } from '../auth/service'; import { CurrentUser } from '../auth/session'; @@ -115,16 +118,26 @@ export class UserResolver { throw new UserNotFound(); } - const avatarBuffer = await readBufferWithLimit(avatar.createReadStream()); - const contentType = sniffMime(avatarBuffer, avatar.mimetype); + const avatarBuffer = await readBufferWithLimit( + avatar.createReadStream(), + 5 * OneMB + ); + const contentType = sniffMime(avatarBuffer, avatar.mimetype)?.toLowerCase(); if (!contentType || !contentType.startsWith('image/')) { - throw new Error(`Invalid file type: ${contentType || 'unknown'}`); + throw new ImageFormatNotSupported({ format: contentType || 'unknown' }); + } + + let processedAvatarBuffer: Buffer; + try { + processedAvatarBuffer = await processImage(avatarBuffer, 512, false); + } catch { + throw new ImageFormatNotSupported({ format: contentType }); } const avatarUrl = await this.storage.put( `${user.id}-avatar-${Date.now()}`, - avatarBuffer, - { contentType } + processedAvatarBuffer, + { contentType: 'image/webp' } ); if (user.avatarUrl) { diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index 6ec908b567..9e6514cdae 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -40,6 +40,7 @@ export function getTokenEncoder(model?: string | null): Tokenizer | null { export const getMime = serverNativeModule.getMime; export const parseDoc = serverNativeModule.parseDoc; export const htmlSanitize = serverNativeModule.htmlSanitize; +export const processImage = serverNativeModule.processImage; export const parseYDocFromBinary = serverNativeModule.parseDocFromBinary; export const parseYDocToMarkdown = serverNativeModule.parseDocToMarkdown; export const parsePageDocFromBinary = serverNativeModule.parsePageDoc; diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 73d1807e85..95dbc29add 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -24,6 +24,7 @@ import { CopilotProviderSideError, CopilotSessionNotFound, type FileUpload, + ImageFormatNotSupported, paginate, Paginated, PaginationInput, @@ -39,6 +40,7 @@ import { DocReader } from '../../core/doc'; import { AccessController, DocAction } from '../../core/permission'; import { UserType } from '../../core/user'; import type { ListSessionOptions, UpdateChatSession } from '../../models'; +import { processImage } from '../../native'; import { CopilotCronJobs } from './cron'; import { PromptService } from './prompt/service'; import { CopilotProviderFactory } from './providers/factory'; @@ -48,6 +50,7 @@ import { CopilotStorage } from './storage'; import { type ChatHistory, type ChatMessage, SubmittedMessage } from './types'; export const COPILOT_LOCKER = 'copilot'; +const COPILOT_IMAGE_MAX_EDGE = 1536; // ================== Input Types ================== @@ -777,19 +780,35 @@ export class CopilotResolver { for (const blob of blobs) { const uploaded = await this.storage.handleUpload(user.id, blob); + const detectedMime = + sniffMime(uploaded.buffer, blob.mimetype)?.toLowerCase() || + blob.mimetype; + let attachmentBuffer = uploaded.buffer; + let attachmentMimeType = detectedMime; + + if (detectedMime.startsWith('image/')) { + try { + attachmentBuffer = await processImage( + uploaded.buffer, + COPILOT_IMAGE_MAX_EDGE, + true + ); + attachmentMimeType = 'image/webp'; + } catch { + throw new ImageFormatNotSupported({ format: detectedMime }); + } + } + const filename = createHash('sha256') - .update(uploaded.buffer) + .update(attachmentBuffer) .digest('base64url'); const attachment = await this.storage.put( user.id, workspaceId, filename, - uploaded.buffer + attachmentBuffer ); - attachments.push({ - attachment, - mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype, - }); + attachments.push({ attachment, mimeType: attachmentMimeType }); } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 79e1fb8033..36ab662cfd 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -877,7 +877,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | ResponseTooLargeErrorDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SsrfBlockedErrorDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | ImageFormatNotSupportedDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | ResponseTooLargeErrorDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SsrfBlockedErrorDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -947,6 +947,7 @@ enum ErrorNames { FAILED_TO_UPSERT_SNAPSHOT GRAPHQL_BAD_REQUEST HTTP_REQUEST_ERROR + IMAGE_FORMAT_NOT_SUPPORTED INTERNAL_SERVER_ERROR INVALID_APP_CONFIG INVALID_APP_CONFIG_INPUT @@ -1095,6 +1096,10 @@ type HttpRequestErrorDataType { message: String! } +type ImageFormatNotSupportedDataType { + format: String! +} + input ImportUsersInput { users: [CreateUserInput!]! } diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index e707e9e6f7..1606a019d4 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -1056,6 +1056,7 @@ export type ErrorDataUnion = | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType + | ImageFormatNotSupportedDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType @@ -1162,6 +1163,7 @@ export enum ErrorNames { FAILED_TO_UPSERT_SNAPSHOT = 'FAILED_TO_UPSERT_SNAPSHOT', GRAPHQL_BAD_REQUEST = 'GRAPHQL_BAD_REQUEST', HTTP_REQUEST_ERROR = 'HTTP_REQUEST_ERROR', + IMAGE_FORMAT_NOT_SUPPORTED = 'IMAGE_FORMAT_NOT_SUPPORTED', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', INVALID_APP_CONFIG = 'INVALID_APP_CONFIG', INVALID_APP_CONFIG_INPUT = 'INVALID_APP_CONFIG_INPUT', @@ -1314,6 +1316,11 @@ export interface HttpRequestErrorDataType { message: Scalars['String']['output']; } +export interface ImageFormatNotSupportedDataType { + __typename?: 'ImageFormatNotSupportedDataType'; + format: Scalars['String']['output']; +} + export interface ImportUsersInput { users: Array; } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 8b0605f10a..5ac38a9fc2 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -6,7 +6,7 @@ "el-GR": 96, "en": 100, "es-AR": 96, - "es-CL": 98, + "es-CL": 97, "es": 96, "fa": 96, "fr": 100, @@ -18,7 +18,7 @@ "pl": 98, "pt-BR": 96, "ru": 98, - "sv-SE": 97, + "sv-SE": 96, "uk": 96, "ur": 2, "zh-Hans": 98, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index a3e32517de..b1aa6dbae0 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8699,6 +8699,12 @@ export function useAFFiNEI18N(): { * `Email service is not configured.` */ ["error.EMAIL_SERVICE_NOT_CONFIGURED"](): string; + /** + * `Image format not supported: {{format}}` + */ + ["error.IMAGE_FORMAT_NOT_SUPPORTED"](options: { + readonly format: string; + }): string; /** * `Query is too long, max length is {{max}}.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 17072ac54e..66f3d6281b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2177,6 +2177,7 @@ "error.SSRF_BLOCKED_ERROR": "Invalid URL", "error.RESPONSE_TOO_LARGE_ERROR": "Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes", "error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.", + "error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}", "error.QUERY_TOO_LONG": "Query is too long, max length is {{max}}.", "error.VALIDATION_ERROR": "Validation error, errors: {{errors}}", "error.USER_NOT_FOUND": "User not found.",