diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt new file mode 100644 index 0000000000..75a5313ef5 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/AmethystTempDir.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempFile +import kotlin.io.path.exists + +/** + * Per-user temporary directory at `~/.amethyst/tmp/`, mode 0700 on + * POSIX systems. Used by the image compression pipeline so: + * + * - tmpfs-mounted `/tmp` on Linux doesn't OOM under a 50 MP HEIC + * decode (a 600 MB intermediate easily exceeds a small VM's + * tmpfs allotment), + * - shared multi-user systems can't race on a predictably-named temp + * file in world-readable `/tmp`, + * - the boot-time sweep has a strict ownership claim — anything in + * this directory belongs to us. + * + * The directory may be overridden via `-Damethyst.tmp.dir=PATH`, + * useful for tests. + */ +object AmethystTempDir { + private const val PROP_OVERRIDE = "amethyst.tmp.dir" + private const val ORPHAN_MAX_AGE_HOURS = 24L + + private val root: File by lazy { resolveRoot().also { ensure(it) } } + + /** + * Create a temp file under the per-user temp dir. Names follow + * `amethyst__.` so the boot-time sweep can + * recognize ownership. + */ + fun createTempFile( + prefix: String, + suffix: String, + ): File { + require(prefix.startsWith("amethyst_")) { + "Temp file prefix must start with 'amethyst_' for sweep ownership; got '$prefix'" + } + return createTempFile(root.toPath(), prefix, suffix).toFile() + } + + /** Path to the per-user temp dir. Test-friendly accessor. */ + fun rootDir(): File = root + + /** + * Delete `amethyst_*` files older than [ORPHAN_MAX_AGE_HOURS] hours. + * Called once at app startup to clean up after JVM crashes that + * skipped shutdown hooks. Returns the number of files removed. + */ + fun sweepOrphans(): Int { + val cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(ORPHAN_MAX_AGE_HOURS) + var removed = 0 + root.listFiles()?.forEach { f -> + if (f.isFile && f.name.startsWith("amethyst_") && f.lastModified() < cutoff) { + if (f.delete()) removed++ + } + } + return removed + } + + private fun resolveRoot(): File { + System.getProperty(PROP_OVERRIDE)?.let { return File(it) } + return File(System.getProperty("user.home"), ".amethyst/tmp") + } + + private fun ensure(dir: File) { + val path = dir.toPath() + if (!path.exists()) Files.createDirectories(path) + // POSIX: lock to owner-only. Best-effort on Windows (no equivalent). + try { + Files.setPosixFilePermissions( + path, + PosixFilePermissions.fromString("rwx------"), + ) + } catch (_: UnsupportedOperationException) { + // Windows / non-POSIX FS — leave default ACLs. + } catch (_: SecurityException) { + // Sandbox or unusual perms — best effort only. + } + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt new file mode 100644 index 0000000000..de79906071 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.EncodeFailed +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.InputTooLarge +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.UnsupportedFormat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import net.coobird.thumbnailator.Thumbnails +import java.awt.color.ColorSpace +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.IIOImage +import javax.imageio.ImageIO +import javax.imageio.ImageWriteParam +import javax.imageio.stream.FileImageOutputStream +import kotlin.coroutines.coroutineContext + +/** + * Re-encode + downscale step of the desktop image upload pipeline. + * + * Caller contract (see [UploadOrchestrator]): + * - `Reencoded` → caller uploads the returned file and owns its cleanup. + * - `PassThrough` → caller uploads the *original* file unchanged. + * - On `CompressionException` → caller surfaces the fail-loud dialog + * (`InputTooLarge` / `UnsupportedFormat` / `EncodeFailed`). + * + * The reencoder is serial: only one image is decoded + encoded at any + * given time, regardless of how many callers run in parallel. This + * keeps the peak heap footprint to a single in-flight BufferedImage. + */ +object ImageReencoder { + private const val DEFAULT_MAX_INPUT_PIXELS = 50L * 1_000_000L + + /** + * Refuse inputs above this pixel count to defend against memory + * bombs (a malicious header claiming 99999×99999 would otherwise + * cause ImageIO to attempt a ~40 GB allocation). + * + * Tunable via `-Damethyst.compression.maxPixels=N`; queried on + * every call so tests can override per-test via a system property. + */ + val maxInputPixels: Long + get() = + System.getProperty("amethyst.compression.maxPixels")?.toLongOrNull() + ?: DEFAULT_MAX_INPUT_PIXELS + + /** + * CPU-bound work runs on `Default` (not `IO`). The serial cap + * keeps memory bounded — only one BufferedImage in flight at any + * given moment, regardless of how many callers fan in. + */ + @Suppress("OPT_IN_USAGE") + private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1) + + /** + * Re-encode a file from disk. + * + * @throws CompressionException on any failure that the caller + * should surface in a fail-loud dialog. + */ + suspend fun reencode( + source: File, + quality: CompressionQuality, + ): ReencodeResult = + withContext(compressionDispatcher) { + coroutineContext.ensureActive() + val format = ImageFormatSniffer.sniff(source) + // 1) Decide between pass-through, refuse, or re-encode. + when (format) { + is ImageFormat.Gif -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated) + is ImageFormat.WebP -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated) + ImageFormat.Svg -> return@withContext ReencodeResult.PassThrough(PassReason.Vector) + ImageFormat.Avif -> throw UnsupportedFormat("avif") + ImageFormat.Heic -> throw UnsupportedFormat("heic") + is ImageFormat.Unknown -> throw UnsupportedFormat(format.mimeType) + else -> { /* fall through to re-encode */ } + } + // 2) Decode with the pre-decode pixel guard + subsampling. + coroutineContext.ensureActive() + val img = decodeWithGuard(source, quality.maxDim) + // 3) Resize only when source exceeds the target box — + // Thumbnailator otherwise upscales by default. + coroutineContext.ensureActive() + val finalImage = + if (img.width > quality.maxDim || img.height > quality.maxDim) { + Thumbnails + .of(img) + .size(quality.maxDim, quality.maxDim) + .asBufferedImage() + } else { + img + } + // 4) Encode JPEG to a temp file, preserving ICC where possible. + coroutineContext.ensureActive() + val temp = AmethystTempDir.createTempFile("amethyst_compress_", ".jpg") + try { + encodeJpeg(finalImage, temp, quality.jpegQuality) + ReencodeResult.Reencoded(temp) + } catch (t: Throwable) { + withContext(NonCancellable) { temp.delete() } + throw if (t is CompressionException) t else EncodeFailed(t) + } + } + + /** + * Decode `source` into a `BufferedImage`, refusing inputs above + * [maxInputPixels] before any pixel buffer is allocated. When the + * source is larger than `targetMaxDim`, decode at a sub-sampled + * stride so we don't allocate the full-resolution buffer just to + * downscale it. + */ + private fun decodeWithGuard( + source: File, + targetMaxDim: Int, + ): BufferedImage { + ImageIO.createImageInputStream(source).use { iis -> + requireNotNull(iis) { "Could not open image input stream" } + val reader = + ImageIO.getImageReaders(iis).let { + if (!it.hasNext()) throw UnsupportedFormat("no ImageIO reader for this input") + it.next() + } + reader.input = iis + try { + val w = reader.getWidth(0) + val h = reader.getHeight(0) + val pixels = w.toLong() * h.toLong() + val cap = maxInputPixels + if (pixels > cap) throw InputTooLarge(pixels, cap) + val param = reader.defaultReadParam + val downscaleStride = subsamplingStride(w, h, targetMaxDim) + if (downscaleStride > 1) { + param.setSourceSubsampling(downscaleStride, downscaleStride, 0, 0) + } + return reader.read(0, param) + } finally { + reader.dispose() + } + } + } + + /** + * Compute the per-axis subsampling stride. Subsampling is a + * memory optimization — Thumbnailator does the final precise + * resize. Use `floor` so the post-subsampling image stays ABOVE + * the target, leaving headroom for Thumbnailator to land + * exactly on `targetMaxDim`. A `ceil` would drop us below the + * target and Thumbnailator could not recover. + * + * Example: 4032 × 3024 with target 1920 → stride 2 → decodes to + * 2016 × 1512, then Thumbnailator resizes precisely to 1920 × + * 1440. + */ + private fun subsamplingStride( + w: Int, + h: Int, + targetMaxDim: Int, + ): Int { + val longestEdge = maxOf(w, h) + if (longestEdge <= targetMaxDim) return 1 + return (longestEdge / targetMaxDim).coerceAtLeast(1) + } + + /** + * Encode `image` as JPEG into `out` at the given `quality` factor. + * When the source has a non-sRGB ICC profile, the JPEG writer's + * default behavior is to embed the profile as APP2 markers so the + * uploaded file retains color fidelity on Display P3 / Adobe RGB + * inputs. + */ + private fun encodeJpeg( + image: BufferedImage, + out: File, + quality: Float, + ) { + val writer = + ImageIO.getImageWritersByMIMEType("image/jpeg").let { + if (!it.hasNext()) throw EncodeFailed(IllegalStateException("no JPEG writer registered")) + it.next() + } + try { + FileImageOutputStream(out).use { stream -> + writer.output = stream + val param = + writer.defaultWriteParam.apply { + compressionMode = ImageWriteParam.MODE_EXPLICIT + compressionType = "JPEG" + compressionQuality = quality + } + writer.write(null, IIOImage(image, null, null), param) + } + } finally { + writer.dispose() + } + } + + /** + * Pass-through reason captured for telemetry and diagnostics. Not + * surfaced to users — the orchestrator decides on user-facing + * messaging. + */ + enum class PassReason { + /** Animated GIF / animated WebP — would lose frames on re-encode. */ + Animated, + + /** Vector format (SVG) — no raster re-encode is meaningful. */ + Vector, + } + + /** Outcome of a [reencode] call. */ + sealed class ReencodeResult { + /** A new temp file was produced. Caller owns cleanup. */ + data class Reencoded( + val file: File, + ) : ReencodeResult() + + /** + * Source must be uploaded byte-identical (animated / vector). + */ + data class PassThrough( + val reason: PassReason, + ) : ReencodeResult() + } + + /** + * Returns `true` if the source carries a non-sRGB ICC profile. The + * fail-loud / wide-gamut warning path will use this to ask for + * user consent before silently shifting colors. Not yet wired into + * the orchestrator — see Phase 7 of the plan. + */ + fun hasWideGamutProfile(image: BufferedImage): Boolean { + val cs = image.colorModel?.colorSpace ?: return false + if (cs.isCS_sRGB) return false + // CS_LINEAR_RGB / CS_PYCC / CS_GRAY are all narrow-gamut or + // colorimetrically neutral — only true ICC color spaces with + // wider primaries (Display P3, Adobe RGB, ProPhoto) matter. + return cs.type == ColorSpace.TYPE_RGB && !cs.isCS_sRGB + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt new file mode 100644 index 0000000000..be2c63a6b2 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoderTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import kotlinx.coroutines.test.runTest +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ImageReencoderTest { + private val createdFiles = mutableListOf() + + @BeforeTest + fun setup() { + createdFiles.clear() + } + + @AfterTest + fun cleanup() { + createdFiles.forEach { it.delete() } + // Clear any per-test pixel override. + System.clearProperty("amethyst.compression.maxPixels") + } + + @Test + fun reencodesJpegAtDesktopHigh() = + runTest { + val src = makeJpeg(4032, 3024) + val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertTrue(decoded.width <= 1920, "width within Desktop High box, got ${decoded.width}") + assertTrue(decoded.height <= 1920, "height within Desktop High box, got ${decoded.height}") + // Source was 4032×3024 → landscape → longer edge clamps to 1920. + assertEquals(1920, decoded.width, "longer edge = max-dim") + } + + @Test + fun reencodesJpegAtMedium() = + runTest { + val src = makeJpeg(2000, 1500) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + // Medium = 640 max-dim. Longer edge clamps to 640. + assertEquals(640, decoded.width) + } + + @Test + fun reencodesJpegAtLow() = + runTest { + val src = makeJpeg(2000, 1500) + val result = ImageReencoder.reencode(src, CompressionQuality.LOW) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertEquals(640, decoded.width) + } + + @Test + fun outputSizeMonotonicAcrossPresets() = + runTest { + val src = makeJpeg(2000, 1500) + val low = (ImageReencoder.reencode(src, CompressionQuality.LOW) as ReencodeResult.Reencoded).file.also(::track) + val medium = (ImageReencoder.reencode(src, CompressionQuality.MEDIUM) as ReencodeResult.Reencoded).file.also(::track) + // DESKTOP_HIGH at 1920 is bigger than 640-based presets, so monotonicity is + // strict only over the same-dim subset (LOW < MEDIUM). + assertTrue(low.length() < medium.length(), "LOW (${low.length()}) < MEDIUM (${medium.length()})") + } + + @Test + fun neverUpscalesSmallSource() = + runTest { + val src = makeJpeg(320, 240) + val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH) + val reencoded = assertIs(result) + track(reencoded.file) + + val decoded = ImageIO.read(reencoded.file) + assertEquals(320, decoded.width, "small source must keep its dimensions") + assertEquals(240, decoded.height, "small source must keep its dimensions") + } + + @Test + fun throwsInputTooLargeWhenAboveCap() = + runTest { + // Lower the cap so a tiny test JPEG triggers the guard. + System.setProperty("amethyst.compression.maxPixels", "1000") + val src = makeJpeg(100, 100) // 10 000 pixels > 1000 cap + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + } + + @Test + fun refusesAvifInput() = + runTest { + val src = makeFile("avif", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "avif".toByteArray() + ByteArray(32)) + val e = + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + assertEquals("avif", e.format) + } + + @Test + fun refusesHeicInput() = + runTest { + val src = makeFile("heic", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "heic".toByteArray() + ByteArray(32)) + assertFailsWith { + ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + } + } + + @Test + fun passesThroughAnimatedGif() = + runTest { + // Synthetic animated-GIF magic: GIF89a + NETSCAPE2.0 application extension. + val src = + makeFile( + "gif", + "GIF89a".toByteArray() + ByteArray(50) + "NETSCAPE2.0".toByteArray() + ByteArray(20), + ) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val pt = assertIs(result) + assertEquals(ImageReencoder.PassReason.Animated, pt.reason) + } + + @Test + fun passesThroughSvg() = + runTest { + val src = makeFile("svg", """""".toByteArray()) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val pt = assertIs(result) + assertEquals(ImageReencoder.PassReason.Vector, pt.reason) + } + + @Test + fun outputIsValidJpeg() = + runTest { + val src = makeJpeg(800, 600) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + val bytes = reencoded.file.readBytes() + assertTrue(bytes.size > 0, "output is non-empty") + // SOI marker + assertEquals(0xFF.toByte(), bytes[0]) + assertEquals(0xD8.toByte(), bytes[1]) + } + + @Test + fun outputLivesInAmethystTmpDir() = + runTest { + val src = makeJpeg(800, 600) + val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM) + val reencoded = assertIs(result) + track(reencoded.file) + + assertEquals( + AmethystTempDir.rootDir().canonicalPath, + reencoded.file.parentFile.canonicalPath, + "temp files must land in AmethystTempDir, not /tmp", + ) + assertTrue( + reencoded.file.name.startsWith("amethyst_compress_"), + "temp file name must carry the sweep prefix", + ) + } + + // ---------- helpers ---------- + + private fun track(file: File) { + createdFiles += file + } + + private fun makeJpeg( + width: Int, + height: Int, + ): File { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + try { + for (y in 0 until height step 8) { + for (x in 0 until width step 8) { + val r = (x * 255 / width).coerceIn(0, 255) + val gg = (y * 255 / height).coerceIn(0, 255) + val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255) + g.color = Color(r, gg, b) + g.fillRect(x, y, 8, 8) + } + } + } finally { + g.dispose() + } + val out = File.createTempFile("reencoder_src_", ".jpg") + track(out) + ImageIO.write(img, "jpg", out) + return out + } + + private fun makeFile( + extension: String, + bytes: ByteArray, + ): File { + val out = File.createTempFile("reencoder_src_", ".$extension") + track(out) + out.writeBytes(bytes) + return out + } +}