Skip to content

PatchyWebStorage.kt

src/jvmMain/kotlin/afield/app/server/PatchyWebStorage.kt · 152 lines

package afield.app.server

import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem

import afield.app.protocol.computeIfAbsent
import afield.app.protocol.put
import afield.audio.synth.*
import afield.data.table.get
import afield.document.workspace.Entry
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.tools.docs.format
import afield.ui.notation.with
import afield.ui.output.OutputRequestBinding
import afield.app.server.SEC_FETCH_SITE_HEADER
import afield.app.server.isSameSiteRequest
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap

The HTTP side of a Patchy editor that edits a .psd on disk: one route pair, addressed by an opaque output-binding id rather than by a path.

patchyWeb registers a binding when a box opens a document file and drops it again when the run stops, so the browser never names a path and a stale id resolves to nothing. Registration refuses anything that is not a .psd inside that document's own files directory, both requests are refused unless they are same-site, and a write to a read-only document is refused as well.

Writes are compare-and-swap on the file's content hash. A GET returns the hash with the bytes, a PUT has to send the hash it is replacing, and a PUT whose hash does not match the file on disk is answered with a conflict rather than overwriting whatever arrived in the meantime. The new bytes have to open with a PSD signature and fit the size limit, and they land through a temporary file moved into place rather than by writing over the original.

internal object PatchyWebStorage

Opaque output-binding IDs are the only browser address for a box's PSD file.

internal object PatchyWebStorage {
    private data class Entry(val binding: OutputRequestBinding, val document: File, val file: File)
    private const val maxBytes = 512L * 1024 * 1024
    private val entries = ConcurrentHashMap<String, Entry>()
    private val locks = ConcurrentHashMap<String, Any>()

fun register(binding: OutputRequestBinding, document: File, file: File)

    fun register(binding: OutputRequestBinding, document: File, file: File) {
        val root = File(document, "files").canonicalFile.toPath()
        val target = file.canonicalFile.toPath()
        require(target.startsWith(root) && target != root && target.fileName.toString().endsWith(".psd", true)) {
            "Patchy documentFile must be a .psd inside this document's files/ directory (try \"%document/patchy.psd\".file)"
        }
        entries[binding.id] = Entry(binding, document.canonicalFile, target.toFile())
    }

fun unregister(id: String) { entries.remove(id) }

    private fun entry(id: String?): Entry? = entries[id]?.takeIf { runCatching { it.binding.requireLive() }.isSuccess }
    private fun revision(file: File): String = if (!file.isFile) "missing" else
        MessageDigest.getInstance("SHA-256").digest(file.readBytes()).joinToString("") { "%02x".format(it) }
    private fun patchySameSite(secFetchSite: String?, origin: String?, host: String?): Boolean =
        isSameSiteRequest(secFetchSite, origin, host)

fun routes(route: Route)

body — 74 lines, from line 78
    fun routes(route: Route) = with(route) {
        route("/api/patchy-document/{binding}") {
            get {
                if (!patchySameSite(call.request.headers[SEC_FETCH_SITE_HEADER], call.request.headers[HttpHeaders.Origin], call.request.headers[HttpHeaders.Host])) {
                    call.respond(HttpStatusCode.Forbidden); return@get
                }
                val entry = entry(call.parameters["binding"])
                    ?: run { call.respond(HttpStatusCode.NotFound); return@get }
                val data = withContext(Dispatchers.IO) {
                    synchronized(locks.computeIfAbsent(entry.file.path) { Any() }) {
                        if (!entry.file.isFile || entry.file.length() > maxBytes) null
                        else entry.file.readBytes().let { it to revision(entry.file) }
                    }
                }
                if (data == null) {
                    call.response.header("X-Patchy-Revision", "missing")
                    call.respond(HttpStatusCode.NotFound)
                } else {
                    call.response.header("X-Patchy-Revision", data.second)
                    call.respondBytes(data.first, ContentType.Application.OctetStream)
                }
            }
            put {
                if (!patchySameSite(call.request.headers[SEC_FETCH_SITE_HEADER], call.request.headers[HttpHeaders.Origin], call.request.headers[HttpHeaders.Host])) {
                    call.respond(HttpStatusCode.Forbidden); return@put
                }
                val entry = entry(call.parameters["binding"])
                    ?: run { call.respond(HttpStatusCode.NotFound); return@put }
                if (ReadOnlyDocuments.blocksWritesTo(entry.document.path)) {
                    call.respond(HttpStatusCode.Forbidden, "Document is read-only"); return@put
                }
                val expected = call.request.headers["X-Patchy-Revision"]
                    ?: run { call.respond(HttpStatusCode(428, "Precondition Required")); return@put }
                val bytes = withContext(Dispatchers.IO) {
                    call.receiveStream().use { input ->
                        val output = java.io.ByteArrayOutputStream()
                        val buffer = ByteArray(64 * 1024)
                        while (true) {
                            val count = input.read(buffer)
                            if (count < 0) break
                            if (output.size().toLong() + count > maxBytes) return@withContext null
                            output.write(buffer, 0, count)
                        }
                        output.toByteArray()
                    }
                } ?: run { call.respond(HttpStatusCode.PayloadTooLarge); return@put }
                if (bytes.size < 4 || String(bytes, 0, 4, Charsets.US_ASCII) != "8BPS") {
                    call.respond(HttpStatusCode.BadRequest, "Expected a PSD file"); return@put
                }
                val result = withContext(Dispatchers.IO) {
                    synchronized(locks.computeIfAbsent(entry.file.path) { Any() }) {
                        if (revision(entry.file) != expected) null else {
                            entry.file.parentFile.mkdirs()
                            val temp = Files.createTempFile(entry.file.parentFile.toPath(), ".patchy-", ".psd.tmp")
                            try {
                                Files.write(temp, bytes)
                                try { Files.move(temp, entry.file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) }
                                catch (_: java.nio.file.AtomicMoveNotSupportedException) {
                                    Files.move(temp, entry.file.toPath(), StandardCopyOption.REPLACE_EXISTING)
                                }
                                revision(entry.file)
                            } finally { Files.deleteIfExists(temp) }
                        }
                    }
                }
                if (result == null) call.respond(HttpStatusCode.Conflict, "The PSD changed on disk")
                else {
                    call.response.header("X-Patchy-Revision", result)
                    call.respond(HttpStatusCode.OK)
                }
            }
        }
    }
}