+import com.nixxcode.jvmbrotli.common.BrotliLoader
+import com.nixxcode.jvmbrotli.enc.BrotliOutputStream
+import com.nixxcode.jvmbrotli.enc.Encoder
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.security.MessageDigest
+import java.util.*
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.zip.GZIPOutputStream
+
+buildscript {
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("com.nixxcode.jvmbrotli:jvmbrotli:0.2.0")
+
+ // why does this need to be done MANUALLY?!?!
+ classpath("com.nixxcode.jvmbrotli:jvmbrotli-win32-x86-amd64:0.2.0")
+ classpath("com.nixxcode.jvmbrotli:jvmbrotli-darwin-x86-amd64:0.2.0")
+ classpath("com.nixxcode.jvmbrotli:jvmbrotli-linux-x86-amd64:0.2.0")
+ }
+}
plugins {
java
implementation("io.ktor:ktor-server-call-id:2.2.3")
implementation("io.ktor:ktor-server-call-logging:2.2.3")
+ implementation("io.ktor:ktor-server-conditional-headers:2.2.3")
implementation("io.ktor:ktor-server-forwarded-header:2.2.3")
implementation("io.ktor:ktor-server-html-builder:2.2.3")
implementation("io.ktor:ktor-server-sessions-jvm:2.2.3")
}
}
+tasks.named<Copy>("processResources") {
+ doLast {
+ val pool = Executors.newWorkStealingPool()
+
+ val encoderParams = if (BrotliLoader.isBrotliAvailable()) Encoder.Parameters().setQuality(8) else null
+ val base64 = Base64.getUrlEncoder()
+ val hashDigest = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
+
+ val resourceTree = fileTree(mapOf("dir" to outputs.files.asPath + "/static/", "exclude" to listOf("*.gz", "*.br", "*.sha256")))
+ val countDownLatch = CountDownLatch(resourceTree.count())
+
+ for (file in resourceTree) {
+ pool.execute {
+ val bytes = file.readBytes()
+ val hashFile = File("${file.absolutePath}.sha256").bufferedWriter()
+ hashFile.write(base64.encodeToString(hashDigest.get().digest(bytes)).trimEnd('='))
+ hashFile.close()
+
+ val result = File("${file.absolutePath}.gz").outputStream()
+ val gzipStream = GZIPOutputStream(result)
+ gzipStream.write(bytes)
+ gzipStream.close()
+
+ encoderParams?.let { encParams ->
+ val brResult = File("${file.absolutePath}.br").outputStream()
+ val brStream = BrotliOutputStream(brResult, encParams)
+ brStream.write(bytes)
+ brStream.close()
+ }
+
+ println("Done compressing ${file.name}")
+ countDownLatch.countDown()
+ }
+ }
+
+ countDownLatch.await()
+ }
+}
+
application {
mainClass.set("info.mechyrdia.Factbooks")
}
import info.mechyrdia.data.*
import info.mechyrdia.lore.*
import io.ktor.http.*
+import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.html.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.callid.*
import io.ktor.server.plugins.callloging.*
+import io.ktor.server.plugins.conditionalheaders.*
import io.ktor.server.plugins.forwardedheaders.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import org.slf4j.event.Level
import java.io.File
import java.io.IOException
+import java.io.InputStream
import java.util.concurrent.atomic.AtomicLong
+object ResourceLoader {
+ fun getResource(resource: String): InputStream? = javaClass.getResourceAsStream(resource)
+
+ val SHA256AttributeKey = AttributeKey<String>("SHA256Hash")
+}
+
lateinit var application: Application
private set
}
}
+ install(ConditionalHeaders) {
+ version { call, _ ->
+ call.attributes.getOrNull(ResourceLoader.SHA256AttributeKey)?.let { hash ->
+ listOf(EntityTagVersion(hash))
+ }.orEmpty()
+ }
+ }
+
install(Sessions) {
cookie("USER_SESSION", SessionStorageMongoDB) {
identity { Id<UserSession>().id }
// Factbooks and assets
static("/static") {
- resources("static")
+ get("{static-content...}") {
+ val staticContentPath = call.parameters.getAll("static-content")?.joinToString("/") ?: return@get
+ val contentPath = "/static/$staticContentPath"
+
+ ResourceLoader.getResource("$contentPath.sha256")?.reader()?.readText()?.let { sha256Hash ->
+ call.attributes.put(ResourceLoader.SHA256AttributeKey, sha256Hash)
+ }
+
+ val brContentPath = "$contentPath.br"
+ val gzContentPath = "$contentPath.gz"
+
+ val contentType = ContentType.fromFileExtension(contentPath.substringAfterLast('.')).firstOrNull()
+
+ val acceptedEncodings = call.request.acceptEncodingItems().map { it.value }.toSet()
+
+ if (CompressedFileType.BROTLI.encoding in acceptedEncodings) {
+ val brContent = ResourceLoader.getResource(brContentPath)
+ if (brContent != null) {
+ call.attributes.put(SuppressionAttribute, true)
+
+ call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.BROTLI.encoding)
+
+ call.respondBytes(brContent.readBytes(), contentType)
+
+ return@get
+ }
+ }
+
+ if (CompressedFileType.GZIP.encoding in acceptedEncodings) {
+ val gzContent = ResourceLoader.getResource(gzContentPath)
+ if (gzContent != null) {
+ call.attributes.put(SuppressionAttribute, true)
+
+ call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.GZIP.encoding)
+
+ call.respondBytes(gzContent.readBytes(), contentType)
+
+ return@get
+ }
+ }
+
+ ResourceLoader.getResource(contentPath)?.let { call.respondBytes(it.readBytes(), contentType) }
+ }
}
get("/lore/{path...}") {