1
0
Fork 1
mirror of https://gitlab.com/mangadex-pub/mangadex_at_home.git synced 2024-01-19 02:48:37 +00:00
mangadex_at_home/src/main/kotlin/mdnet/base/server/ImageServer.kt

364 lines
14 KiB
Kotlin
Raw Normal View History

2020-06-22 17:02:36 +00:00
/*
Mangadex@Home
Copyright (c) 2020, MangaDex Network
This file is part of MangaDex@Home.
MangaDex@Home is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MangaDex@Home is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
2020-06-13 22:36:26 +00:00
/* ktlint-disable no-wildcard-imports */
2020-06-13 23:19:04 +00:00
package mdnet.base.server
2020-06-13 22:36:26 +00:00
2020-07-04 19:00:59 +00:00
import com.fasterxml.jackson.core.JsonProcessingException
2020-07-02 21:24:12 +00:00
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.goterl.lazycode.lazysodium.LazySodiumJava
import com.goterl.lazycode.lazysodium.SodiumJava
import com.goterl.lazycode.lazysodium.exceptions.SodiumException
2020-07-02 16:06:32 +00:00
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
2020-07-03 21:22:40 +00:00
import java.net.InetAddress
2020-07-02 16:06:32 +00:00
import java.security.MessageDigest
import java.time.Clock
2020-07-02 21:24:12 +00:00
import java.time.OffsetDateTime
import java.util.*
2020-07-02 16:06:32 +00:00
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
2020-06-13 22:36:26 +00:00
import mdnet.base.Constants
import mdnet.base.data.ImageData
import mdnet.base.data.ImageDatum
2020-07-02 16:06:32 +00:00
import mdnet.base.data.Statistics
2020-07-02 21:24:12 +00:00
import mdnet.base.data.Token
import mdnet.base.settings.ServerSettings
2020-06-13 22:36:26 +00:00
import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.ApacheClient
import org.http4k.core.*
import org.http4k.filter.CachingFilters
import org.http4k.filter.CorsPolicy
import org.http4k.filter.ServerFilters
2020-06-13 22:36:26 +00:00
import org.http4k.lens.Path
2020-06-15 22:25:31 +00:00
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
2020-06-13 22:36:26 +00:00
import org.slf4j.LoggerFactory
2020-06-21 19:49:10 +00:00
private const val THREADS_TO_ALLOCATE = 262144 // 2**18
2020-06-13 22:36:26 +00:00
2020-07-03 21:22:40 +00:00
class ImageServer(private val cache: DiskLruCache, private val statistics: AtomicReference<Statistics>, private val serverSettings: ServerSettings, private val database: Database, private val clientHostname: String, private val handled: AtomicBoolean) {
2020-06-15 22:25:31 +00:00
init {
transaction(database) {
SchemaUtils.create(ImageData)
}
}
2020-06-13 22:36:26 +00:00
private val executor = Executors.newCachedThreadPool()
private val client = ApacheClient(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
2020-07-01 18:48:32 +00:00
.disableConnectionState()
2020-06-13 22:36:26 +00:00
.setDefaultRequestConfig(
RequestConfig.custom()
2020-07-02 21:24:12 +00:00
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
2020-07-04 17:11:24 +00:00
.apply {
2020-07-03 21:22:40 +00:00
if (clientHostname != "0.0.0.0") {
2020-07-04 17:11:24 +00:00
setLocalAddress(InetAddress.getByName(clientHostname))
2020-07-03 21:22:40 +00:00
}
2020-07-04 17:11:24 +00:00
}
2020-07-02 21:24:12 +00:00
.build())
2020-06-13 22:36:26 +00:00
.setMaxConnTotal(THREADS_TO_ALLOCATE)
.setMaxConnPerRoute(THREADS_TO_ALLOCATE)
.build())
2020-07-02 21:24:12 +00:00
fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler {
val sodium = LazySodiumJava(SodiumJava())
return baseHandler().then { request ->
val chapterHash = Path.of("chapterHash")(request)
val fileName = Path.of("fileName")(request)
val sanitizedUri = if (dataSaver) {
"/data-saver"
} else {
"/data"
} + "/$chapterHash/$fileName"
2020-07-02 21:50:50 +00:00
if (tokenized || serverSettings.forceTokens) {
2020-07-02 21:24:12 +00:00
val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request))
2020-07-04 19:00:59 +00:00
val token = try {
JACKSON.readValue<Token>(
try {
sodium.cryptoBoxOpenEasyAfterNm(
tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24), serverSettings.tokenKey
)
} catch (_: SodiumException) {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri rejected for invalid token")
}
return@then Response(Status.FORBIDDEN)
2020-07-02 21:24:12 +00:00
}
2020-07-04 19:00:59 +00:00
)
} catch (e: JsonProcessingException) {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri rejected for invalid token")
2020-07-02 21:24:12 +00:00
}
2020-07-04 19:00:59 +00:00
return@then Response(Status.FORBIDDEN)
}
2020-07-02 21:24:12 +00:00
if (OffsetDateTime.now().isAfter(token.expires)) {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri rejected for expired token")
}
return@then Response(Status.GONE)
}
2020-06-13 22:36:26 +00:00
2020-07-02 21:24:12 +00:00
if (token.hash != chapterHash) {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri rejected for inapplicable token")
}
return@then Response(Status.FORBIDDEN)
}
}
2020-06-13 22:36:26 +00:00
2020-07-02 21:24:12 +00:00
statistics.getAndUpdate {
it.copy(requestsServed = it.requestsServed + 1)
}
2020-06-15 22:25:31 +00:00
2020-07-02 21:24:12 +00:00
val rc4Bytes = if (dataSaver) {
md5Bytes("saver$chapterHash.$fileName")
} else {
md5Bytes("$chapterHash.$fileName")
}
2020-07-02 21:24:12 +00:00
val imageId = printHexString(rc4Bytes)
2020-06-13 22:36:26 +00:00
2020-07-02 21:24:12 +00:00
val snapshot = cache.getUnsafe(imageId.toCacheId())
val imageDatum = synchronized(database) {
transaction(database) {
ImageDatum.findById(imageId)
2020-06-15 22:25:31 +00:00
}
}
2020-07-02 21:24:12 +00:00
handled.set(true)
if (request.header("Referer")?.startsWith("https://mangadex.org") == false) {
snapshot?.close()
Response(Status.FORBIDDEN)
} else if (snapshot != null && imageDatum != null) {
request.handleCacheHit(sanitizedUri, getRc4(rc4Bytes), snapshot, imageDatum)
} else {
if (snapshot != null) {
snapshot.close()
2020-07-04 15:27:43 +00:00
2020-07-02 21:24:12 +00:00
if (LOGGER.isWarnEnabled) {
LOGGER.warn("Removing cache file for $sanitizedUri without corresponding DB entry")
}
cache.removeUnsafe(imageId.toCacheId())
2020-06-15 22:25:31 +00:00
}
2020-07-04 15:27:43 +00:00
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
2020-07-02 21:24:12 +00:00
}
2020-06-13 22:36:26 +00:00
}
}
2020-06-15 22:25:31 +00:00
private fun Request.handleCacheHit(sanitizedUri: String, cipher: Cipher, snapshot: DiskLruCache.Snapshot, imageDatum: ImageDatum): Response {
2020-06-13 22:36:26 +00:00
// our files never change, so it's safe to use the browser cache
return if (this.header("If-Modified-Since") != null) {
statistics.getAndUpdate {
it.copy(browserCached = it.browserCached + 1)
}
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri cached by browser")
}
2020-06-16 23:00:28 +00:00
val lastModified = imageDatum.lastModified
2020-06-13 22:36:26 +00:00
snapshot.close()
Response(Status.NOT_MODIFIED)
.header("Last-Modified", lastModified)
} else {
statistics.getAndUpdate {
it.copy(cacheHits = it.cacheHits + 1)
}
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri hit cache")
}
respondWithImage(
CipherInputStream(BufferedInputStream(snapshot.getInputStream(0)), cipher),
2020-06-15 22:25:31 +00:00
snapshot.getLength(0).toString(), imageDatum.contentType, imageDatum.lastModified,
2020-06-13 22:36:26 +00:00
true
)
}
}
2020-07-04 15:27:43 +00:00
private fun Request.handleCacheMiss(sanitizedUri: String, cipher: Cipher, imageId: String, imageDatum: ImageDatum?): Response {
2020-06-13 22:36:26 +00:00
if (LOGGER.isInfoEnabled) {
LOGGER.info("Request for $sanitizedUri missed cache")
}
statistics.getAndUpdate {
it.copy(cacheMisses = it.cacheMisses + 1)
}
2020-07-02 21:24:12 +00:00
val mdResponse = client(Request(Method.GET, "${serverSettings.imageServer}$sanitizedUri"))
2020-06-13 22:36:26 +00:00
if (mdResponse.status != Status.OK) {
if (LOGGER.isTraceEnabled) {
LOGGER.trace("Upstream query for $sanitizedUri errored with status {}", mdResponse.status)
}
mdResponse.close()
return Response(mdResponse.status)
}
if (LOGGER.isTraceEnabled) {
LOGGER.trace("Upstream query for $sanitizedUri succeeded")
}
val contentType = mdResponse.header("Content-Type")!!
val contentLength = mdResponse.header("Content-Length")
val lastModified = mdResponse.header("Last-Modified")
2020-06-15 22:25:31 +00:00
val editor = cache.editUnsafe(imageId.toCacheId())
2020-06-13 22:36:26 +00:00
// A null editor means that this file is being written to
// concurrently so we skip the cache process
return if (editor != null && contentLength != null && lastModified != null) {
if (LOGGER.isTraceEnabled) {
LOGGER.trace("Request for $sanitizedUri is being cached and served")
}
2020-06-15 22:25:31 +00:00
2020-07-04 15:29:49 +00:00
if (imageDatum == null) {
2020-07-04 15:27:43 +00:00
synchronized(database) {
transaction(database) {
ImageDatum.new(imageId) {
this.contentType = contentType
this.lastModified = lastModified
}
}
2020-06-15 22:25:31 +00:00
}
}
2020-06-13 22:36:26 +00:00
val tee = CachingInputStream(
mdResponse.body.stream,
executor, CipherOutputStream(BufferedOutputStream(editor.newOutputStream(0)), cipher)
) {
2020-06-15 22:25:31 +00:00
try {
if (editor.getLength(0) == contentLength.toLong()) {
if (LOGGER.isInfoEnabled) {
LOGGER.info("Cache download for $sanitizedUri committed")
}
editor.commit()
} else {
if (LOGGER.isWarnEnabled) {
LOGGER.warn("Cache download for $sanitizedUri aborted")
2020-06-15 22:25:31 +00:00
}
editor.abort()
2020-06-13 22:36:26 +00:00
}
2020-06-15 22:25:31 +00:00
} catch (e: Exception) {
if (LOGGER.isWarnEnabled) {
LOGGER.warn("Cache go/no go for $sanitizedUri failed", e)
2020-06-13 22:36:26 +00:00
}
}
}
respondWithImage(tee, contentLength, contentType, lastModified, false)
} else {
editor?.abort()
if (LOGGER.isTraceEnabled) {
LOGGER.trace("Request for $sanitizedUri is being served")
}
respondWithImage(mdResponse.body.stream, contentLength, contentType, lastModified, false)
}
}
2020-06-15 22:25:31 +00:00
private fun String.toCacheId() =
this.substring(0, 8).replace("..(?!$)".toRegex(), "$0 ").split(" ".toRegex())
.plus(this).joinToString(File.separator)
2020-06-13 22:36:26 +00:00
private fun respondWithImage(input: InputStream, length: String?, type: String, lastModified: String?, cached: Boolean): Response =
Response(Status.OK)
.header("Content-Type", type)
.header("X-Content-Type-Options", "nosniff")
.let {
if (length != null) {
it.body(input, length.toLong()).header("Content-Length", length)
} else {
it.body(input).header("Transfer-Encoding", "chunked")
}
}
.let {
if (lastModified != null) {
it.header("Last-Modified", lastModified)
} else {
it
}
2020-06-14 11:04:20 +00:00
}
.header("X-Cache", if (cached) "HIT" else "MISS")
2020-06-21 19:49:10 +00:00
companion object {
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
2020-07-02 21:24:12 +00:00
private val JACKSON: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JavaTimeModule())
private fun baseHandler(): Filter =
CachingFilters.Response.MaxAge(Clock.systemUTC(), Constants.MAX_AGE_CACHE)
.then(ServerFilters.Cors(
2020-07-02 21:24:12 +00:00
CorsPolicy(
origins = listOf("https://mangadex.org"),
headers = listOf("*"),
methods = Method.values().toList()
)
)
2020-07-02 21:24:12 +00:00
)
.then(Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
response.header("timing-allow-origin", "https://mangadex.org")
}
})
2020-06-21 19:49:10 +00:00
}
2020-06-13 22:36:26 +00:00
}
private fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
return rc4
}
private fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())
}
private fun printHexString(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}