Skip to content

ThreeReviewEvidence.kt

src/jvmMain/kotlin/afield/graphics/three/ThreeReviewEvidence.kt · 103 lines

package afield.graphics.three

import afield.geometry.path.div
import afield.geometry.path.minus
import afield.geometry.path.plus
import afield.geometry.path.times
import afield.lang.remotejs.JsFun

import afield.geometry.path.length
import kotlinx.serialization.json.*
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
import java.util.Base64
import javax.imageio.ImageIO

Stores the screenshots a geometry review note carries as PNG files beside the document rather than as base64 inside it.

The browser sends a context image and a crop of it, each as a data URL, with the viewport and crop rectangle they were taken from. Kept in the note, those would put megabytes of base64 into a text property that is saved, versioned and diffed on every edit. store writes each image into the document's .geometry-review directory under its own SHA-256 and returns the note's metadata with a /three-review-image URL in place of the bytes, so a second note of the same pixels adds a reference and no file.

The validation is what makes those files safe to serve back. The images have to agree with the note about geometry digest, view and panel; the payload has to be a PNG by signature and by decoding, at the dimensions the browser declared, within 1600 px for the context image and 1024 px for the crop; no image may exceed two million bytes and the directory may not exceed sixty-four million. read answers only for a 64-character hex name that one of the notes it was handed actually cites, so the route cannot be talked into returning a path of the caller's choosing or an image belonging to another view.

internal object ThreeReviewEvidence

Bounded PNG sidecars keep screenshots out of the document's text property.

internal object ThreeReviewEvidence {
    private const val MAX_IMAGE_BYTES = 2_000_000
    private const val MAX_DIRECTORY_BYTES = 64_000_000L
    private val hashPattern = Regex("[0-9a-f]{64}")

fun store(evidence: JsonObject, record: JsonObject, directory: Path): JsonObject

body — 50 lines, from line 43
    fun store(evidence: JsonObject, record: JsonObject, directory: Path): JsonObject {
        require(evidence["schemaVersion"]?.jsonPrimitive?.intOrNull == 1) { "Unknown image evidence version" }
        require(evidence["artifactDigest"] == record["artifactDigest"] && evidence["view"] == record["view"]) { "Images and saved view disagree" }
        // On a contact sheet the images are of the panel the feature was picked in.
        evidence["panel"]?.let { panel ->
            require(panel.jsonPrimitive.intOrNull?.let { it >= 0 } == true && panel == record["selection"]?.jsonObject?.get("panel")) { "Images and feature name different panels" }
        }
        for ((key, size) in listOf("viewport" to 2, "cropBounds" to 4)) {
            val values = evidence[key] as? JsonArray ?: error("Missing image $key")
            require(values.size == size && values.all { it.jsonPrimitive.doubleOrNull?.isFinite() == true }) { "Invalid image $key" }
        }
        val decoded = listOf("context", "crop").associateWith { kind ->
            val image = evidence[kind]?.jsonObject ?: error("Missing $kind image")
            val encoded = image["dataUrl"]?.jsonPrimitive?.contentOrNull ?: error("Missing PNG image")
            require(encoded.startsWith("data:image/png;base64,") && encoded.length <= MAX_IMAGE_BYTES * 4 / 3 + 100) { "Image is too large or is not PNG" }
            val bytes = Base64.getDecoder().decode(encoded.substringAfter(','))
            require(bytes.size <= MAX_IMAGE_BYTES && bytes.take(8) == listOf(137,80,78,71,13,10,26,10).map { it.toByte() }) { "Invalid PNG image" }
            ImageIO.createImageInputStream(bytes.inputStream()).use { input ->
                val reader = ImageIO.getImageReaders(input).asSequence().firstOrNull() ?: error("Unreadable PNG image")
                try {
                    reader.input = input
                    val width = reader.getWidth(0); val height = reader.getHeight(0)
                    val limit = if (kind == "context") 1600 else 1024
                    require(width in 1..limit && height in 1..limit &&
                        width == image["width"]?.jsonPrimitive?.intOrNull && height == image["height"]?.jsonPrimitive?.intOrNull) { "Invalid image dimensions" }
                    checkNotNull(reader.read(0)) { "Incomplete PNG image" }
                } finally { reader.dispose() }
            }
            bytes
        }
        val metadata = decoded.mapValues { (kind, bytes) ->
            val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
            JsonObject(evidence.getValue(kind).jsonObject.filterKeys { it in setOf("width", "height") } + mapOf(
                "sha256" to JsonPrimitive(hash), "mimeType" to JsonPrimitive("image/png"), "bytes" to JsonPrimitive(bytes.size),
                "url" to JsonPrimitive("/three-review-image?uid=" + java.net.URLEncoder.encode(record.getValue("viewId").jsonPrimitive.content, Charsets.UTF_8) + "&sha256=" + hash)))
        }
        Files.createDirectories(directory)
        val used = Files.list(directory).use { paths -> paths.filter { Files.isRegularFile(it) }.mapToLong { Files.size(it) }.sum() }
        val newBytes = decoded.filterKeys { !Files.exists(directory.resolve(metadata.getValue(it).getValue("sha256").jsonPrimitive.content + ".png")) }.values.sumOf { it.size.toLong() }
        require(used + newBytes <= MAX_DIRECTORY_BYTES) { "Review images are full; export and archive them before adding more" }
        for ((kind, bytes) in decoded) {
            val path = directory.resolve(metadata.getValue(kind).getValue("sha256").jsonPrimitive.content + ".png")
            if (!Files.exists(path)) {
                val temporary = Files.createTempFile(directory, "image-", ".tmp")
                try { Files.write(temporary, bytes); Files.move(temporary, path, java.nio.file.StandardCopyOption.ATOMIC_MOVE) }
                finally { Files.deleteIfExists(temporary) }
            }
        }
        return JsonObject(evidence.filterKeys { it in setOf("schemaVersion", "artifactDigest", "viewport", "panel", "cropBounds") } + metadata)
    }

fun read(hash: String, notes: List<JsonObject>, directory: Path): ByteArray?

    fun read(hash: String, notes: List<JsonObject>, directory: Path): ByteArray? {
        if (!hashPattern.matches(hash)) return null
        if (notes.none { note -> (note["evidence"] as? JsonObject)?.let { evidence ->
            listOf("context", "crop").any { (evidence[it] as? JsonObject)?.get("sha256")?.jsonPrimitive?.contentOrNull == hash }
        } == true }) return null
        val file = directory.resolve("$hash.png")
        return if (Files.isRegularFile(file) && Files.size(file) <= MAX_IMAGE_BYTES) Files.readAllBytes(file) else null
    }
}