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")
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.*
fun Application.factbooks() {
application = this
+ install(AutoHeadResponse)
install(IgnoreTrailingSlash)
- install(XForwardedHeaders)
+
+ install(XForwardedHeaders) {
+ useLastProxy()
+ }
install(CachingHeaders) {
options { call, outgoingContent ->
call.respondRedirect(url, permanent)
}
exception<AprilFoolsStaticFileRedirectException> { call, (replacement) ->
- call.respondFile(replacement)
+ call.respondCompressedFile(replacement)
}
exception<MissingRequestParameterException> { call, _ ->
call.respondHtml(HttpStatusCode.BadRequest, call.error400())
staticResources("/static", "static", index = null) {
preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
- enableAutoHeadResponse()
}
get("/lore/{path...}") {
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") {
return funnyFile.takeIf { it.exists() }
}
-fun redirectStaticFileOnApril1st(requestedFile: File) {
+fun redirectAssetOnApril1st(requestedFile: File) {
redirectFileOnApril1st(requestedFile)?.let { throw AprilFoolsStaticFileRedirectException(it) }
}
--- /dev/null
+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<File, CompressedCacheEntry>()
+
+ 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
+ }
+}