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

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

package otoroshi.next.plugins

import akka.http.scaladsl.model.Uri
import akka.stream.Materializer
import akka.stream.scaladsl.{Source, StreamConverters}
import akka.util.ByteString
import otoroshi.el.GlobalExpressionLanguage
import otoroshi.env.Env
import otoroshi.next.plugins.api._
import otoroshi.next.proxy.NgProxyEngineError
import otoroshi.utils.syntax.implicits._
import play.api.libs.json._
import play.api.mvc.Results

import java.io.File
import java.nio.file.Files
import java.util.zip.ZipFile
import scala.collection.concurrent.TrieMap
import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters.enumerationAsScalaIteratorConverter
import scala.util._

case class ZipFileBackendConfig(
    url: String,
    headers: Map[String, String],
    dir: String,
    prefix: Option[String],
    ttl: Long
) extends NgPluginConfig {
  def json: JsValue = ZipFileBackendConfig.format.writes(this)
}

object ZipFileBackendConfig {
  val default = ZipFileBackendConfig(
    "https://github.com/MAIF/otoroshi/releases/download/16.11.2/otoroshi-manual-16.11.2.zip",
    Map.empty,
    "./zips",
    None,
    1.hour.toMillis
  )
  val format  = new Format[ZipFileBackendConfig] {
    override def writes(o: ZipFileBackendConfig): JsValue = Json.obj(
      "url"     -> o.url,
      "headers" -> o.headers,
      "dir"     -> o.dir,
      "prefix"  -> o.prefix,
      "ttl"     -> o.ttl
    )
    override def reads(json: JsValue): JsResult[ZipFileBackendConfig] = {
      Try {
        ZipFileBackendConfig(
          url = json.select("url").asString,
          headers = json.select("headers").asOpt[Map[String, String]].getOrElse(Map.empty),
          dir = json.select("dir").asOpt[String].getOrElse(ZipFileBackendConfig.default.dir),
          prefix = json.select("prefix").asOpt[String].filter(_.nonEmpty),
          ttl = json.select("ttl").asOpt[Long].getOrElse(ZipFileBackendConfig.default.ttl)
        )
      } match {
        case Failure(e) => JsError(e.getMessage)
        case Success(s) => JsSuccess(s)
      }
    }
  }
}

class ZipFileBackend extends NgBackendCall {

  override def useDelegates: Boolean                       = true
  override def multiInstance: Boolean                      = true
  override def defaultConfigObject: Option[NgPluginConfig] = Some(ZipFileBackendConfig.default)
  override def core: Boolean                               = false
  override def name: String                                = "Zip file backend"
  override def description: Option[String]                 = "Serves content from a zip file".some
  override def visibility: NgPluginVisibility              = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory]           = Seq(NgPluginCategory.Other)
  override def steps: Seq[NgStep]                          = Seq(NgStep.CallBackend)

  private val fileCache = new TrieMap[String, (Long, Promise[String])]()

  private def getZipFile(
      config: ZipFileBackendConfig
  )(implicit env: Env, ec: ExecutionContext): Future[Either[String, ZipFile]] = fileCache.synchronized {
    val url = config.url
    if (url.startsWith("file://")) {
      Right(new ZipFile(url.replace("file://", ""))).vfuture
    } else {
      val dir      = new File(config.dir)
      if (!dir.exists()) {
        dir.mkdirs()
      }
      val filename = s"${config.dir}/${url.sha256}.zip"
      fileCache.get(filename) match {
        case Some((at, fu)) if at + config.ttl < System.currentTimeMillis() => {
          new File(filename).delete()
          fileCache.remove(filename)
          getZipFile(config)
        }
        case Some((_, fu))                                                  => fu.future.map(s => Right(new ZipFile(s)))
        case None                                                           => {
          val file = new File(filename)
          if (!file.exists()) {
            fileCache.put(filename, (System.currentTimeMillis(), Promise[String]()))
            env.Ws.url(url).withFollowRedirects(true).withRequestTimeout(30.seconds).get().map { resp =>
              if (resp.status == 200) {
                Files.write(file.toPath, resp.bodyAsBytes.toArray[Byte])
                fileCache.get(filename).foreach(_._2.trySuccess(filename))
                Right(new ZipFile(filename))
              } else {
                fileCache.remove(filename)
                println(s"not found: ${url}")
                Left(s"url not found: ${resp.status} - ${resp.headers} - ${resp.body}")
              }
            }
          } else {
            fileCache.get(filename).foreach(_._2.trySuccess(filename))
            Right(new ZipFile(filename)).vfuture
          }
        }
      }
    }
  }

  private def atPath(_path: String, zip: ZipFile, config: ZipFileBackendConfig)(implicit
      env: Env
  ): Option[(String, Source[ByteString, _])] = {
    var path =
      if (_path == "/") "index.html"
      else {
        if (_path.startsWith("/")) {
          _path.substring(1)
        } else {
          _path
        }
      }
    if (path.endsWith("/")) {
      path = path + "index.html"
    }
    if (config.prefix.isDefined) {
      path = config.prefix.get + path
    }
    if (path.startsWith("/")) {
      path = path.substring(1)
    }
    Option(zip.getEntry(path)).flatMap { entry =>
      if (entry.isDirectory) {
        None
      } else {
        val uriPath  = Uri.apply(path).path
        val filename = if (uriPath.length < 2) {
          uriPath.toString()
        } else {
          uriPath.tail.reverse.head.toString
        }
        if (filename.contains(".")) {
          val ext              = filename.split("\\.").toSeq.last
          val mimeType: String = env.devMimetypes.getOrElse(ext, "text/plain")
          Some((mimeType, StreamConverters.fromInputStream(() => zip.getInputStream(entry))))
        } else {
          Some(("text/html", StreamConverters.fromInputStream(() => zip.getInputStream(entry))))
        }
      }
    }
  }

  override def callBackend(
      ctx: NgbBackendCallContext,
      delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
  )(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
    val config = ctx.cachedConfig(internalName)(ZipFileBackendConfig.format).getOrElse(ZipFileBackendConfig.default)
    getZipFile(
      config.copy(url =
        GlobalExpressionLanguage.apply(
          value = config.url,
          req = ctx.rawRequest.some,
          service = None,
          route = ctx.route.some,
          apiKey = ctx.apikey,
          user = ctx.user,
          context = Map.empty,
          attrs = ctx.attrs,
          env = env
        )
      )
    ).map {
      case Left(msg)      =>
        Left(NgProxyEngineError.NgResultProxyEngineError(Results.InternalServerError(Json.obj("error" -> msg))))
      case Right(zipfile) => {
        val path = ctx.request.path
        atPath(path, zipfile, config) match {
          case Some((contentType, body)) => {
            Right(
              BackendCallResponse(
                NgPluginHttpResponse(
                  200,
                  Map("Content-Type" -> contentType, "Transfer-Encoding" -> "chunked"),
                  Seq.empty,
                  body
                ),
                None
              )
            )
          }
          case None                      => {
            val body = "

File not found !

".byteString Right( BackendCallResponse( NgPluginHttpResponse( 404, Map("Content-Type" -> "text/html", "Content-Length" -> body.size.toString), Seq.empty, body.chunks(32 * 8) ), None ) ) } } } } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy