diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift index 0d482882f1..8c180c31e6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -112,7 +112,7 @@ private func imagesWithinBudget() -> Modifier { guard parts.count == 2, parts[0].hasSuffix(";base64"), let data = Data(base64Encoded: String(parts[1])), - data.starts(with: [0xFF, 0xD8, 0xFF]) || data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + ImageFileType.jpeg.matches(data) || ImageFileType.png.matches(data), let size = UIImage(data: data)?.size, size.width <= MAX_MARKDOWN_IMAGE_DIMENSION, size.height <= MAX_MARKDOWN_IMAGE_DIMENSION, diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index a035039b12..4b1d480a92 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -24,6 +24,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? { let filePath = getAppFilePath(fileSource.filePath) do { let data = try getFileData(filePath, fileSource.cryptoArgs) + guard isImageFile(data) else { return nil } let img = UIImage(data: data) do { try img?.setGifFromData(data, levelOfIntegrity: 1.0) @@ -38,6 +39,36 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? { return nil } +public enum ImageFileType: CaseIterable { + case jpeg + case png + case gif + case webp + + var signatures: [[UInt8?]] { + switch self { + case .jpeg: [[0xFF, 0xD8, 0xFF]] + case .png: [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]] + case .gif: [ascii("GIF87a"), ascii("GIF89a")] + case .webp: [ascii("RIFF") + [nil, nil, nil, nil] + ascii("WEBP")] + } + } + + public func matches(_ data: Data) -> Bool { + signatures.contains { signature in + data.count >= signature.count && zip(signature, data).allSatisfy { b, d in b == nil || b == d } + } + } +} + +public func isImageFile(_ data: Data) -> Bool { + ImageFileType.allCases.contains { $0.matches(data) } +} + +private func ascii(_ s: String) -> [UInt8?] { + s.utf8.map { $0 } +} + public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { if let cfArgs = cfArgs { return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 7b2f53d365..7ece114f8c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -183,6 +183,7 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? } else { File(getAppFilePath(file.fileName)).readBytes() } + if (!isImageFile(data)) return null decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index addfc17e73..321257f6cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -29,6 +29,21 @@ expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap expect fun isImage(uri: URI): Boolean expect fun isAnimImage(uri: URI, drawable: Any?): Boolean +enum class ImageFileType(val signatures: List>) { + JPEG(listOf(listOf(0xFF, 0xD8, 0xFF))), + PNG(listOf(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))), + GIF(listOf(ascii("GIF87a"), ascii("GIF89a"))), + WEBP(listOf(ascii("RIFF") + List(4) { null } + ascii("WEBP"))); + + fun matches(data: ByteArray): Boolean = signatures.any { signature -> + data.size >= signature.size && signature.withIndex().all { (i, b) -> b == null || b == data[i].toInt() and 0xFF } + } +} + +fun isImageFile(data: ByteArray): Boolean = ImageFileType.entries.any { it.matches(data) } + +private fun ascii(s: String): List = s.map { it.code } + expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap expect fun resizeImageFileUri(uri: URI, maxSize: Int): String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt index ecc38bc681..a763e47d43 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -54,10 +54,7 @@ import java.nio.charset.CodingErrorAction import java.util.Base64 private const val DATA_IMAGE = "data:image/" -private val IMAGE_SIGNATURES = listOf( - listOf(0xFF, 0xD8, 0xFF), - listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) -) +private val IMAGE_TYPES = listOf(ImageFileType.JPEG, ImageFileType.PNG) private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 private const val MAX_MARKDOWN_DEPTH = 32 @@ -295,6 +292,5 @@ private class DataImageTransformer : ImageTransformer { private fun hasImageSignature(base64: String): Boolean { val header = runCatching { Base64.getDecoder().decode(base64.take(12)) }.getOrNull() ?: return false - val bytes = header.map { it.toInt() and 0xFF } - return IMAGE_SIGNATURES.any { bytes.take(it.size) == it } + return IMAGE_TYPES.any { it.matches(header) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index d4c42790d2..8be83b0645 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -142,6 +142,7 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? return if (filePath != null) { loadedImageCache[filePath] ?: try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() + if (!isImageFile(data)) return null val bitmap = decodeBoundedImage(data, MAX_THUMBNAIL_DIMENSION) (bitmap to data).also { loadedImageCache[filePath] = it } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageFileTypeTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageFileTypeTest.kt new file mode 100644 index 0000000000..89be02ec23 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageFileTypeTest.kt @@ -0,0 +1,43 @@ +package chat.simplex.app + +import chat.simplex.common.platform.ImageFileType +import chat.simplex.common.platform.isImageFile +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +class ImageFileTypeTest { + @Test + fun testJpegImagesLoad() { + assertTrue(ImageFileType.JPEG.matches(encoded("jpg"))) + assertTrue(isImageFile(encoded("jpg"))) + } + + @Test + fun testPngImagesLoad() { + assertTrue(ImageFileType.PNG.matches(encoded("png"))) + assertTrue(isImageFile(encoded("png"))) + } + + @Test + fun testGifImagesLoad() { + assertTrue(ImageFileType.GIF.matches(encoded("gif"))) + assertTrue(isImageFile(encoded("gif"))) + } + + @Test + fun testWebpImagesLoad() { + assertTrue(ImageFileType.WEBP.matches(webpHeader)) + assertTrue(isImageFile(webpHeader)) + } + + private fun encoded(format: String): ByteArray { + val out = ByteArrayOutputStream() + ImageIO.write(BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), format, out) + return out.toByteArray() + } + + private val webpHeader = listOf(0x52, 0x49, 0x46, 0x46, 0x36, 0xC8, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x58).map { it.toByte() }.toByteArray() +}