All Downloads are FREE. Search and download functionalities are using the official Maven repository.

next.plugins.brotli.scala Maven / Gradle / Ivy

package otoroshi.next.plugins

import akka.stream.Materializer
import akka.util.ByteString
import com.nixxcode.jvmbrotli.common.BrotliLoader
import com.nixxcode.jvmbrotli.enc.Encoder
import otoroshi.env.Env
import otoroshi.next.plugins.api._
import otoroshi.utils.RegexPool
import otoroshi.utils.gzip.GzipConfig
import otoroshi.utils.http.RequestImplicits.EnhancedRequestHeader
import otoroshi.utils.syntax.implicits._
import play.api.http.HeaderNames.{ACCEPT_ENCODING, CONTENT_ENCODING, VARY}
import play.api.http.{MediaType, Status}
import play.api.libs.json._
import play.api.mvc.{Headers, RequestHeader, Result}

import scala.concurrent.ExecutionContext
import scala.util.{Failure, Success, Try}

case class NgBrotliConfig(
    whiteList: Seq[String] = Seq("text/*", "application/javascript", "application/json"),
    blackList: Seq[String] = Seq.empty[String],
    bufferSize: Int = 8192,
    chunkedThreshold: Int = 102400,
    compressionLevel: Int = 5
) extends NgPluginConfig {
  def json: JsValue = NgBrotliConfig.format.writes(this)
}

object NgBrotliConfig {
  val format: Format[NgBrotliConfig] = new Format[NgBrotliConfig] {
    override def reads(json: JsValue): JsResult[NgBrotliConfig] =
      Try {
        NgBrotliConfig(
          whiteList = (json \ "allowed_list").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          blackList = (json \ "blocked_list").asOpt[Seq[String]].getOrElse(Seq.empty[String]),
          bufferSize = (json \ "buffer_size").asOpt[Int].getOrElse(8192),
          chunkedThreshold = (json \ "chunked_threshold").asOpt[Int].getOrElse(102400),
          compressionLevel = (json \ "compression_level").asOpt[Int].getOrElse(5)
        )
      } match {
        case Success(entity) => JsSuccess(entity)
        case Failure(err)    => JsError(err.getMessage)
      }

    override def writes(o: NgBrotliConfig): JsValue =
      Json.obj(
        "allowed_list"      -> o.whiteList,
        "blocked_list"      -> o.blackList,
        "buffer_size"       -> o.bufferSize,
        "chunked_threshold" -> o.chunkedThreshold,
        "compression_level" -> o.compressionLevel
      )
  }
}

class BrotliResponseCompressor extends NgRequestTransformer {

  private val configReads: Reads[NgBrotliConfig] = NgBrotliConfig.format

  override def steps: Seq[NgStep]                = Seq(NgStep.TransformResponse)
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Transformations)
  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand

  override def multiInstance: Boolean                      = true
  override def core: Boolean                               = true
  override def usesCallbacks: Boolean                      = false
  override def transformsRequest: Boolean                  = false
  override def transformsResponse: Boolean                 = true
  override def transformsError: Boolean                    = false
  override def isTransformRequestAsync: Boolean            = false
  override def isTransformResponseAsync: Boolean           = false
  override def name: String                                = "Brotli compression"
  override def description: Option[String]                 = "This plugin can compress responses using brotli".some
  override def defaultConfigObject: Option[NgPluginConfig] = NgGzipConfig().some

  override def transformResponseSync(
      ctx: NgTransformerResponseContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Either[Result, NgPluginHttpResponse] = {
    val config  = ctx.cachedConfig(internalName)(configReads).getOrElse(NgBrotliConfig())
    val request = ctx.request
    if (
      mayCompress(request) && shouldCompress(ctx.otoroshiResponse) && shouldBrotli(
        config,
        request,
        ctx.otoroshiResponse
      )
    ) {
      BrotliLoader.isBrotliAvailable()
      val params = new Encoder.Parameters().setQuality(config.compressionLevel)
      val vary   = varyWith(ctx.otoroshiResponse.headers, ACCEPT_ENCODING)
      ctx.otoroshiResponse
        .copy(
          headers = ctx.otoroshiResponse.headers - "Content-Length" ++ Map(
            "Content-Encoding"  -> "br",
            "Transfer-Encoding" -> "chunked",
            vary._1             -> vary._2
          ),
          body = ctx.otoroshiResponse.body.map { bs =>
            ByteString.apply(Encoder.compress(bs.toArray, params))
          }
        )
        .right
    } else {
      ctx.otoroshiResponse.right
    }
  }

  private def mayCompress(request: RequestHeader) =
    request.method != "HEAD" && brotliIsAcceptedAndPreferredBy(request)

  private def acceptHeader(headers: Headers, headerName: String): Seq[(Double, String)] = {
    for {
      header <- headers.get(headerName).toList
      value0 <- header.split(',')
      value   = value0.trim
    } yield {
      RequestHeader.qPattern.findFirstMatchIn(value) match {
        case Some(m) => (m.group(1).toDouble, m.before.toString)
        case None    => (1.0, value) // “The default value is q=1.”
      }
    }
  }

  private def brotliIsAcceptedAndPreferredBy(request: RequestHeader) = {
    val codings = acceptHeader(request.headers, ACCEPT_ENCODING)

    def explicitQValue(coding: String) = codings.collectFirst { case (q, c) if c.equalsIgnoreCase(coding) => q }

    def defaultQValue(coding: String) = if (coding == "identity") 0.001d else 0d

    def qvalue(coding: String) = explicitQValue(coding).orElse(explicitQValue("*")).getOrElse(defaultQValue(coding))

    qvalue("br") > 0d && qvalue("br") >= qvalue("identity")
  }

  private def shouldCompress(result: NgPluginHttpResponse) =
    isAllowedContent(result) && isNotAlreadyCompressed(result)

  private def isAllowedContent(header: NgPluginHttpResponse) =
    header.status != Status.NO_CONTENT && header.status != Status.NOT_MODIFIED

  private def isNotAlreadyCompressed(header: NgPluginHttpResponse) =
    header.headers.getIgnoreCase(CONTENT_ENCODING).isEmpty

  private def varyWith(rh: Map[String, String], headerValues: String*): (String, String) = {
    val newValue = rh.getIgnoreCase(VARY) match {
      case Some(existing) if existing.nonEmpty =>
        val existingSet: Set[String] = existing.split(",").map(_.trim.toLowerCase)(collection.breakOut)
        val newValuesToAdd           = headerValues.filterNot(v => existingSet.contains(v.trim.toLowerCase))
        s"$existing${newValuesToAdd.map(v => s",$v").mkString}"
      case _                                   =>
        headerValues.mkString(",")
    }
    VARY -> newValue
  }

  private def parseConfigMediaTypes(types: Seq[String]): Seq[MediaType] = {
    val mediaTypes = types.flatMap {
      case "*"                        => Some(MediaType("*", "*", Seq.empty))
      case MediaType.parse(mediaType) => Some(mediaType)
      case invalid                    =>
        GzipConfig.logger.error(s"Failed to parse the configured MediaType mask '$invalid'")
        None
    }
    mediaTypes.foreach {
      case MediaType("*", "*", _) =>
      case _                      => () // the configured MediaType mask is valid
    }
    mediaTypes
  }

  private def matches(outgoing: MediaType, mask: MediaType): Boolean = {
    def capturedByMask(value: String, mask: String): Boolean = {
      mask == "*" || value.equalsIgnoreCase(mask)
    }

    capturedByMask(outgoing.mediaType, mask.mediaType) && capturedByMask(outgoing.mediaSubType, mask.mediaSubType)
  }

  private def shouldBrotli(config: NgBrotliConfig, req: RequestHeader, res: NgPluginHttpResponse): Boolean = {
    lazy val whiteListParsed = parseConfigMediaTypes(config.whiteList)
    lazy val blackListParsed = parseConfigMediaTypes(config.blackList)
    if (whiteListParsed.isEmpty) {

      if (blackListParsed.isEmpty) {
        true // default case, both whitelist and blacklist are empty so we brotli it.
      } else {
        // The blacklist is defined, so we brotli the result if it's not blacklisted.
        res.contentType match {
          case Some(MediaType.parse(outgoing)) => blackListParsed.forall(mask => !matches(outgoing, mask))
          case _                               => true // Fail open (to brotling), since blacklists have a tendency to fail open.
        }
      }
    } else {
      // The whitelist is defined. We brotli the result IFF there is a matching whitelist entry.
      res.contentType match {
        case Some(MediaType.parse(outgoing)) => whiteListParsed.exists(mask => matches(outgoing, mask))
        case _                               => false // Fail closed (to not brotling), since whitelists are intentionally strict.
      }
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy