From: Lanius Trolling Date: Thu, 28 Mar 2024 23:02:04 +0000 (-0400) Subject: Implement lazy asset-file compression X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=82e8c6216c142ae14dd7d1c2a5b61ae5dd0ac79d;p=factbooks Implement lazy asset-file compression --- diff --git a/build.gradle.kts b/build.gradle.kts index b2ec0e2..488b63b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -116,17 +116,18 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.2") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.2") - implementation("io.ktor:ktor-server-core-jvm:2.3.8") - implementation("io.ktor:ktor-server-cio-jvm:2.3.8") + implementation("io.ktor:ktor-server-core-jvm:2.3.9") + implementation("io.ktor:ktor-server-cio-jvm:2.3.9") - implementation("io.ktor:ktor-server-caching-headers:2.3.8") - implementation("io.ktor:ktor-server-call-id:2.3.8") - implementation("io.ktor:ktor-server-call-logging:2.3.8") - implementation("io.ktor:ktor-server-conditional-headers:2.3.8") - implementation("io.ktor:ktor-server-forwarded-header:2.3.8") - implementation("io.ktor:ktor-server-html-builder:2.3.8") - implementation("io.ktor:ktor-server-sessions-jvm:2.3.8") - implementation("io.ktor:ktor-server-status-pages:2.3.8") + implementation("io.ktor:ktor-server-auto-head-response:2.3.9") + implementation("io.ktor:ktor-server-caching-headers:2.3.9") + implementation("io.ktor:ktor-server-call-id:2.3.9") + implementation("io.ktor:ktor-server-call-logging:2.3.9") + implementation("io.ktor:ktor-server-conditional-headers:2.3.9") + implementation("io.ktor:ktor-server-forwarded-header:2.3.9") + implementation("io.ktor:ktor-server-html-builder:2.3.9") + implementation("io.ktor:ktor-server-sessions-jvm:2.3.9") + implementation("io.ktor:ktor-server-status-pages:2.3.9") implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0") diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index 1a2ec5e..f888434 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -13,6 +13,7 @@ import io.ktor.server.engine.* import io.ktor.server.html.* import io.ktor.server.http.content.* import io.ktor.server.plugins.* +import io.ktor.server.plugins.autohead.* import io.ktor.server.plugins.cachingheaders.* import io.ktor.server.plugins.callid.* import io.ktor.server.plugins.callloging.* @@ -48,8 +49,12 @@ fun main() { fun Application.factbooks() { application = this + install(AutoHeadResponse) install(IgnoreTrailingSlash) - install(XForwardedHeaders) + + install(XForwardedHeaders) { + useLastProxy() + } install(CachingHeaders) { options { call, outgoingContent -> @@ -109,7 +114,7 @@ fun Application.factbooks() { call.respondRedirect(url, permanent) } exception { call, (replacement) -> - call.respondFile(replacement) + call.respondCompressedFile(replacement) } exception { call, _ -> call.respondHtml(HttpStatusCode.BadRequest, call.error400()) @@ -144,7 +149,6 @@ fun Application.factbooks() { staticResources("/static", "static", index = null) { preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP) - enableAutoHeadResponse() } get("/lore/{path...}") { @@ -155,11 +159,12 @@ fun Application.factbooks() { call.respondHtml(HttpStatusCode.OK, call.loreRawArticlePage("")) } - staticFiles("/assets", File(Configuration.CurrentConfiguration.assetDir), index = null) { - enableAutoHeadResponse() - modify { file, _ -> - redirectStaticFileOnApril1st(file) - } + get("/assets/{path...}") { + val assetPath = call.parameters.getAll("path")?.joinToString(separator = File.separator) ?: return@get + val assetFile = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath) + + redirectAssetOnApril1st(assetFile) + call.respondCompressedFile(assetFile) } get("/map") { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt index b075d0d..b178ad3 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt @@ -25,6 +25,6 @@ fun redirectFileOnApril1st(requestedFile: File): File? { return funnyFile.takeIf { it.exists() } } -fun redirectStaticFileOnApril1st(requestedFile: File) { +fun redirectAssetOnApril1st(requestedFile: File) { redirectFileOnApril1st(requestedFile)?.let { throw AprilFoolsStaticFileRedirectException(it) } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt new file mode 100644 index 0000000..e933ea4 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt @@ -0,0 +1,80 @@ +package info.mechyrdia.lore + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FilterOutputStream +import java.io.OutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.zip.DeflaterOutputStream +import java.util.zip.GZIPOutputStream + +private val gzippedCache = CompressedCache.fromCompressorFactory("gzip", ::GZIPOutputStream) +private val deflatedCache = CompressedCache.fromCompressorFactory("deflate", ::DeflaterOutputStream) + +private fun getCacheByEncoding(encoding: String) = when (encoding) { + "gzip" -> gzippedCache + "deflate" -> deflatedCache + else -> null +} + +private fun ApplicationCall.compressedCache(): CompressedCache? { + return request.acceptEncodingItems() + .mapNotNull { value -> getCacheByEncoding(value.value)?.let { it to value.quality } } + .maxByOrNull { it.second } + ?.first +} + +suspend fun ApplicationCall.respondCompressedFile(file: File) { + val cache = compressedCache() ?: return respondFile(file) + response.header(HttpHeaders.ContentEncoding, cache.encoding) + respondBytes(cache.getCompressed(file)) +} + +private class CompressedCache(val encoding: String, private val compressor: (ByteArray) -> ByteArray) { + private val cache = ConcurrentHashMap() + + fun getCompressed(file: File): ByteArray { + val lastModified = file.lastModified() + return cache.compute(file) { _, prevEntry -> + if (prevEntry == null || prevEntry.lastModified < lastModified) + CompressedCacheEntry(lastModified, compressor(file.readBytes())) + else prevEntry + }!!.compressedData + } + + companion object { + fun fromCompressorFactory(encoding: String, compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) = CompressedCache(encoding) { uncompressed -> + ByteArrayOutputStream().also { oStream -> + compressorFactory(oStream, true).use { gzip -> + gzip.write(uncompressed) + gzip.flush() + } + }.toByteArray() + } + } +} + +private data class CompressedCacheEntry( + val lastModified: Long, + val compressedData: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CompressedCacheEntry) return false + + if (lastModified != other.lastModified) return false + if (!compressedData.contentEquals(other.compressedData)) return false + + return true + } + + override fun hashCode(): Int { + var result = lastModified.hashCode() + result = 31 * result + compressedData.contentHashCode() + return result + } +}