MiniDigger/Hangar

View on GitHub
ore/app/controllers/project/Versions.scala

Summary

Maintainability
F
3 days
Test Coverage
package controllers.project

import java.nio.file.Files._
import java.nio.file.{Files, StandardCopyOption}
import java.time.Instant
import java.util.UUID
import javax.inject.{Inject, Singleton}

import play.api.i18n.{Lang, MessagesApi}
import play.api.mvc.{Action, AnyContent, Result}
import play.filters.csrf.CSRF

import controllers.sugar.Requests.{AuthRequest, OreRequest, ProjectRequest}
import controllers.{OreBaseController, OreControllerComponents}
import discourse.OreDiscourseApi
import form.OreForms
import models.viewhelper.VersionData
import ore.data.DownloadType
import ore.db.access.ModelView
import ore.db.impl.OrePostgresDriver.api._
import ore.db.impl.schema.UserTable
import ore.db.{DbRef, Model}
import ore.markdown.MarkdownRenderer
import ore.models.admin.VersionVisibilityChange
import ore.models.project._
import ore.models.project.factory.ProjectFactory
import ore.models.project.io.{PluginFile, PluginUpload, ProjectFiles}
import ore.models.user.{LoggedAction, User}
import ore.permission.Permission
import ore.util.OreMDC
import ore.util.StringUtils._
import ore.{OreEnv, StatTracker}
import util.UserActionLogger
import util.syntax._
import views.html.projects.{versions => views}

import cats.data.{EitherT, OptionT}
import cats.effect.IO
import cats.instances.option._
import cats.syntax.all._
import com.github.tminglei.slickpg.InetString
import com.typesafe.scalalogging
import _root_.io.circe.Json
import _root_.io.circe.syntax._

/**
  * Controller for handling Version related actions.
  */
@Singleton
class Versions @Inject()(stats: StatTracker[IO], forms: OreForms, factory: ProjectFactory)(
    implicit oreComponents: OreControllerComponents[IO],
    messagesApi: MessagesApi,
    env: OreEnv,
    forums: OreDiscourseApi[IO],
    renderer: MarkdownRenderer,
    fileManager: ProjectFiles
) extends OreBaseController {

  private val self = controllers.project.routes.Versions

  private val Logger    = scalalogging.Logger("Versions")
  private val MDCLogger = scalalogging.Logger.takingImplicit[OreMDC](Logger.underlying)

  private def VersionEditAction(author: String, slug: String) =
    AuthedProjectAction(author, slug, requireUnlock = true).andThen(ProjectPermissionAction(Permission.EditVersion))

  private def VersionUploadAction(author: String, slug: String) =
    AuthedProjectAction(author, slug, requireUnlock = true).andThen(ProjectPermissionAction(Permission.CreateVersion))

  /**
    * Shows the specified version view page.
    *
    * @param author        Owner name
    * @param slug          Project slug
    * @param versionString Version name
    * @return Version view
    */
  def show(author: String, slug: String, versionString: String): Action[AnyContent] =
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      for {
        version <- getVersion(request.project, versionString)
        data    <- EitherT.right[Result](VersionData.of(request, version))
        response <- EitherT.right[Result](
          this.stats.projectViewed(IO.pure(Ok(views.view(data, request.scoped))))
        )
      } yield response
    }

  /**
    * Saves the specified Version's description.
    *
    * @param author        Project owner
    * @param slug          Project slug
    * @param versionString Version name
    * @return View of Version
    */
  def saveDescription(author: String, slug: String, versionString: String): Action[String] = {
    VersionEditAction(author, slug).asyncEitherT(parse.form(forms.VersionDescription)) { implicit request =>
      for {
        version <- getVersion(request.project, versionString)
        oldDescription = version.description.getOrElse("")
        newDescription = request.body.trim
        _ <- EitherT.right[Result](version.updateForumContents(newDescription))
        _ <- EitherT.right[Result](
          UserActionLogger.log(
            request.request,
            LoggedAction.VersionDescriptionEdited,
            version.id,
            newDescription,
            oldDescription
          )
        )
      } yield Redirect(self.show(author, slug, versionString))
    }
  }

  /**
    * Sets the specified Version as the recommended download.
    *
    * @param author         Project owner
    * @param slug           Project slug
    * @param versionString  Version name
    * @return               View of version
    */
  def setRecommended(author: String, slug: String, versionString: String): Action[AnyContent] = {
    VersionEditAction(author, slug).asyncEitherT { implicit request =>
      for {
        version <- getVersion(request.project, versionString)
        _ <- EitherT.right[Result](
          service.update(request.project)(_.copy(recommendedVersionId = Some(version.id)))
        )
        _ <- EitherT.right[Result](
          UserActionLogger.log(
            request.request,
            LoggedAction.VersionAsRecommended,
            version.id,
            "recommended version",
            "listed version"
          )
        )
      } yield Redirect(self.show(author, slug, versionString))
    }
  }

  /**
    * Sets the specified Version as approved by the moderation staff.
    *
    * @param author         Project owner
    * @param slug           Project slug
    * @param versionString  Version name
    * @return               View of version
    */
  def approve(author: String, slug: String, versionString: String, partial: Boolean): Action[AnyContent] = {
    AuthedProjectAction(author, slug, requireUnlock = true)
      .andThen(ProjectPermissionAction(Permission.Reviewer))
      .asyncEitherT { implicit request =>
        val newState = if (partial) ReviewState.PartiallyReviewed else ReviewState.Reviewed
        for {
          version <- getVersion(request.data.project, versionString)
          _ <- EitherT.right[Result](
            service.update(version)(
              _.copy(
                reviewState = newState,
                reviewerId = Some(request.user.id),
                approvedAt = Some(Instant.now())
              )
            )
          )
          _ <- EitherT.right[Result](
            UserActionLogger.log(
              request.request,
              LoggedAction.VersionReviewStateChanged,
              version.id,
              newState.toString,
              version.reviewState.toString,
            )
          )
        } yield Redirect(self.show(author, slug, versionString))
      }
  }

  /**
    * Displays the "versions" tab within a Project view.
    *
    * @param author   Owner of project
    * @param slug     Project slug
    * @return View of project
    */
  def showList(author: String, slug: String): Action[AnyContent] = {
    ProjectAction(author, slug).asyncF { implicit request =>
      val allChannelsDBIO = request.project.channels(ModelView.raw(Channel)).result

      service.runDBIO(allChannelsDBIO).flatMap { allChannels =>
        this.stats.projectViewed(
          IO.pure(
            Ok(
              views.list(
                request.data,
                request.scoped,
                Model.unwrapNested(allChannels)
              )
            )
          )
        )
      }
    }
  }

  /**
    * Shows the creation form for new versions on projects.
    *
    * @param author Owner of project
    * @param slug   Project slug
    * @return Version creation view
    */
  def showCreator(author: String, slug: String): Action[AnyContent] =
    VersionUploadAction(author, slug).asyncF { implicit request =>
      service.runDBIO(request.project.channels(ModelView.raw(Channel)).result).map { channels =>
        val project = request.project
        Ok(
          views.create(
            project.name,
            project.pluginId,
            project.slug,
            project.ownerName,
            project.description,
            forumSync = request.data.settings.forumSync,
            None,
            Model.unwrapNested(channels)
          )
        )
      }
    }

  /**
    * Uploads a new version for a project for further processing.
    *
    * @param author Owner name
    * @param slug   Project slug
    * @return Version create page (with meta)
    */
  def upload(author: String, slug: String): Action[AnyContent] = VersionUploadAction(author, slug).asyncEitherT {
    implicit request =>
      val call = self.showCreator(author, slug)
      val user = request.user

      val uploadData = this.factory
        .getUploadError(user)
        .map(error => Redirect(call).withError(error))
        .toLeft(())
        .flatMap(_ => PluginUpload.bindFromRequest().toRight(Redirect(call).withError("error.noFile")))

      EitherT
        .fromEither[IO](uploadData)
        .flatMap { data =>
          this.factory
            .processSubsequentPluginUpload(data, user, request.data.project)
            .leftMap(err => Redirect(call).withError(err))
        }
        .semiflatMap { pendingVersion =>
          pendingVersion
            .copy(authorId = user.id)
            .cache[IO]
            .as(
              Redirect(
                self.showCreatorWithMeta(request.data.project.ownerName, slug, pendingVersion.versionString)
              )
            )
        }
  }

  /**
    * Displays the "version create" page with the associated plugin meta-data.
    *
    * @param author        Owner name
    * @param slug          Project slug
    * @param versionString Version name
    * @return Version create view
    */
  def showCreatorWithMeta(author: String, slug: String, versionString: String): Action[AnyContent] =
    UserLock(ShowProject(author, slug)).asyncF { implicit request =>
      val success = OptionT
        .fromOption[IO](this.factory.getPendingVersion(author, slug, versionString))
        // Get pending version
        .flatMap(pendingVersion => projects.withSlug(author, slug).tupleLeft(pendingVersion))
        .semiflatMap {
          case (pendingVersion, project) =>
            val projectData = project.settings.map { settings =>
              (project.name, project.pluginId, project.slug, project.ownerName, project.description, settings.forumSync)
            }
            (service.runDBIO(project.channels(ModelView.raw(Channel)).result), projectData)
              .parMapN((channels, data) => (channels, data, pendingVersion))
        }
        .map {
          case (
              channels,
              (projectName, pluginId, projectSlug, ownerName, projectDescription, forumSync),
              pendingVersion
              ) =>
            Ok(
              views.create(
                projectName,
                pluginId,
                projectSlug,
                ownerName,
                projectDescription,
                forumSync,
                Some(pendingVersion),
                Model.unwrapNested(channels)
              )
            )
        }

      success.getOrElse(Redirect(self.showCreator(author, slug)).withError("error.plugin.timeout"))
    }

  /**
    * Completes the creation of the specified pending version or project if
    * first version.
    *
    * @param author        Owner name
    * @param slug          Project slug
    * @param versionString Version name
    * @return New version view
    */
  def publish(author: String, slug: String, versionString: String): Action[AnyContent] = {
    UserLock(ShowProject(author, slug)).asyncF { implicit request =>
      // First get the pending Version
      this.factory.getPendingVersion(author, slug, versionString) match {
        case None =>
          // Not found
          IO.pure(Redirect(self.showCreator(author, slug)).withError("error.plugin.timeout"))
        case Some(pendingVersion) =>
          // Get submitted channel
          this.forms.VersionCreate.bindFromRequest.fold(
            // Invalid channel
            FormError(self.showCreatorWithMeta(author, slug, versionString)).andThen(IO.pure),
            versionData => {
              // Channel is valid

              val newPendingVersion = pendingVersion.copy(
                channelName = versionData.channelName.trim,
                channelColor = versionData.color,
                createForumPost = versionData.forumPost,
                description = versionData.content
              )

              val createVersion = getProject(author, slug).flatMap {
                project =>
                  project
                    .channels(ModelView.now(Channel))
                    .find(equalsIgnoreCase(_.name, newPendingVersion.channelName))
                    .toRight(versionData.addTo(project))
                    .leftFlatMap(identity)
                    .semiflatMap {
                      _ =>
                        newPendingVersion
                          .complete(project, factory)
                          .map(t => t._1 -> t._2)
                          .flatTap {
                            case (newProject, newVersion) =>
                              if (versionData.recommended)
                                service
                                  .update(newProject)(
                                    _.copy(
                                      recommendedVersionId = Some(newVersion.id),
                                      lastUpdated = Instant.now()
                                    )
                                  )
                                  .void
                              else
                                service
                                  .update(newProject)(
                                    _.copy(
                                      lastUpdated = Instant.now()
                                    )
                                  )
                                  .void
                          }
                          .flatTap(t => addUnstableTag(t._2, versionData.unstable))
                          .flatTap {
                            case (_, newVersion) =>
                              UserActionLogger.log(
                                request,
                                LoggedAction.VersionUploaded,
                                newVersion.id,
                                "published",
                                "null"
                              )
                          }
                          .as(Redirect(self.show(author, slug, versionString)))
                    }
                    .leftMap(Redirect(self.showCreatorWithMeta(author, slug, versionString)).withErrors(_))
              }.merge

              newPendingVersion.exists.ifM(
                IO.pure(Redirect(self.showCreator(author, slug)).withError("error.plugin.versionExists")),
                createVersion
              )
            }
          )
      }
    }
  }

  private def addUnstableTag(version: Model[Version], unstable: Boolean) = {
    if (unstable) {
      service
        .insert(
          VersionTag(
            versionId = version.id,
            name = "Unstable",
            data = "",
            color = TagColor.Unstable
          )
        )
        .void
    } else IO.unit
  }

  /**
    * Deletes the specified version and returns to the version page.
    *
    * @param author        Owner name
    * @param slug          Project slug
    * @param versionString Version name
    * @return Versions page
    */
  def delete(author: String, slug: String, versionString: String): Action[String] = {
    Authenticated
      .andThen(PermissionAction[AuthRequest](Permission.HardDeleteVersion))
      .asyncEitherT(parse.form(forms.NeedsChanges)) { implicit request =>
        val comment = request.body
        getProjectVersion(author, slug, versionString)
          .semiflatMap(version => projects.deleteVersion(version).as(version))
          .semiflatMap { version =>
            UserActionLogger
              .log(
                request,
                LoggedAction.VersionDeleted,
                version.id,
                s"Deleted: $comment",
                s"$version.visibility"
              )
          }
          .map(_ => Redirect(self.showList(author, slug)))
      }
  }

  /**
    * Soft deletes the specified version.
    *
    * @param author Project owner
    * @param slug   Project slug
    * @return Home page
    */
  def softDelete(author: String, slug: String, versionString: String): Action[String] =
    AuthedProjectAction(author, slug, requireUnlock = true)
      .andThen(ProjectPermissionAction(Permission.DeleteVersion))
      .asyncEitherT(parse.form(forms.NeedsChanges)) { implicit request =>
        val comment = request.body
        getVersion(request.project, versionString)
          .semiflatMap(version => projects.prepareDeleteVersion(version).as(version))
          .semiflatMap { version =>
            version.setVisibility(Visibility.SoftDelete, comment, request.user.id).as(version)
          }
          .semiflatMap { version =>
            UserActionLogger
              .log(request.request, LoggedAction.VersionDeleted, version.id, s"SoftDelete: $comment", "")
          }
          .map(_ => Redirect(self.showList(author, slug)))
      }

  /**
    * Restore the specified version.
    *
    * @param author Project owner
    * @param slug   Project slug
    * @return Home page
    */
  def restore(author: String, slug: String, versionString: String): Action[String] = {
    Authenticated
      .andThen(PermissionAction[AuthRequest](Permission.Reviewer))
      .asyncEitherT(parse.form(forms.NeedsChanges)) { implicit request =>
        val comment = request.body
        getProjectVersion(author, slug, versionString)
          .semiflatMap(version => version.setVisibility(Visibility.Public, comment, request.user.id).as(version))
          .semiflatMap { version =>
            UserActionLogger.log(request, LoggedAction.VersionDeleted, version.id, s"Restore: $comment", "")
          }
          .map(_ => Redirect(self.showList(author, slug)))
      }
  }

  def showLog(author: String, slug: String, versionString: String): Action[AnyContent] = {
    Authenticated
      .andThen(PermissionAction[AuthRequest](Permission.ViewLogs))
      .andThen(ProjectAction(author, slug))
      .asyncEitherT { implicit request =>
        for {
          version <- getVersion(request.project, versionString)
          visChanges <- EitherT.right[Result](
            service.runDBIO(
              version
                .visibilityChangesByDate(ModelView.raw(VersionVisibilityChange))
                .joinLeft(TableQuery[UserTable])
                .on(_.createdBy === _.id)
                .result
            )
          )
        } yield
          Ok(
            views.log(
              request.project,
              version,
              Model.unwrapNested[Seq[(Model[VersionVisibilityChange], Option[User])]](visChanges)
            )
          )
      }
  }

  /**
    * Sends the specified Project Version to the client.
    *
    * @param author        Project owner
    * @param slug          Project slug
    * @param versionString Version string
    * @return Sent file
    */
  def download(author: String, slug: String, versionString: String, token: Option[String]): Action[AnyContent] =
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      val project = request.project
      getVersion(project, versionString).semiflatMap(sendVersion(project, _, token))
    }

  private def sendVersion(project: Project, version: Model[Version], token: Option[String])(
      implicit req: ProjectRequest[_]
  ): IO[Result] = {
    checkConfirmation(version, token).flatMap { passed =>
      if (passed)
        _sendVersion(project, version)
      else
        IO.pure(
          Redirect(
            self.showDownloadConfirm(
              project.ownerName,
              project.slug,
              version.name,
              Some(DownloadType.UploadedFile.value),
              api = Some(false),
              Some("dummy")
            )
          )
        )
    }
  }

  private def checkConfirmation(version: Model[Version], token: Option[String])(
      implicit req: ProjectRequest[_]
  ): IO[Boolean] = {
    if (version.reviewState == ReviewState.Reviewed)
      IO.pure(true)
    else {
      val hasSessionConfirm = req.session.get(DownloadWarning.cookieKey(version.id)).contains("confirmed")

      if (hasSessionConfirm) {
        IO.pure(true)
      } else {
        // check confirmation for API
        OptionT
          .fromOption[IO](token)
          .flatMap { tkn =>
            ModelView.now(DownloadWarning).find { warn =>
              (warn.token === tkn) &&
              (warn.versionId === version.id.value) &&
              (warn.address === InetString(StatTracker.remoteAddress)) &&
              warn.isConfirmed
            }
          }
          .semiflatMap(warn => if (warn.hasExpired) service.delete(warn).as(false) else IO.pure(true))
          .exists(identity)
      }
    }
  }

  private def _sendVersion(project: Project, version: Model[Version])(implicit req: ProjectRequest[_]): IO[Result] =
    this.stats.versionDownloaded(version) {
      IO.pure {
        Ok.sendPath(
          this.fileManager
            .getVersionDir(project.ownerName, project.name, version.name)
            .resolve(version.fileName)
        )
      }
    }

  private val MultipleChoices = new Status(MULTIPLE_CHOICES)

  /**
    * Displays a confirmation view for downloading unreviewed versions. The
    * client is issued a unique token that will be checked once downloading to
    * ensure that they have landed on this confirmation before downloading the
    * version.
    *
    * @param author Project author
    * @param slug   Project slug
    * @param target Target version
    * @param dummy  A parameter to get around Chrome's cache
    * @return       Confirmation view
    */
  def showDownloadConfirm(
      author: String,
      slug: String,
      target: String,
      downloadType: Option[Int],
      api: Option[Boolean],
      dummy: Option[String]
  ): Action[AnyContent] = {
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      val dlType              = downloadType.flatMap(DownloadType.withValueOpt).getOrElse(DownloadType.UploadedFile)
      implicit val lang: Lang = request.lang
      val project             = request.project
      getVersion(project, target)
        .ensure(Redirect(ShowProject(author, slug)).withError("error.plugin.stateChanged"))(
          _.reviewState != ReviewState.Reviewed
        )
        .semiflatMap { version =>
          // generate a unique "warning" object to ensure the user has landed
          // on the warning before downloading
          val token      = UUID.randomUUID().toString
          val expiration = Instant.now().plusMillis(this.config.security.unsafeDownloadMaxAge)
          val address    = InetString(StatTracker.remoteAddress)
          // remove old warning attached to address that are expired (or duplicated for version)
          val removeWarnings = service.deleteWhere(DownloadWarning) { warning =>
            (warning.address === address || warning.expiration < Instant
              .now()) && warning.versionId === version.id.value
          }
          // create warning
          val addWarning = service.insert(
            DownloadWarning(
              expiration = expiration,
              token = token,
              versionId = version.id,
              address = address,
              downloadId = None
            )
          )

          val isPartial   = version.reviewState == ReviewState.PartiallyReviewed
          val apiMsgKey   = if (isPartial) "version.download.confirmPartial.api" else "version.download.confirm.body.api"
          lazy val apiMsg = this.messagesApi(apiMsgKey)

          lazy val curlInstruction = this.messagesApi(
            "version.download.confirm.curl",
            self.confirmDownload(author, slug, target, Some(dlType.value), Some(token), None).absoluteURL(),
            CSRF.getToken.get.value
          )

          if (api.getOrElse(false)) {
            (removeWarnings *> addWarning).as(
              MultipleChoices(
                Json
                  .obj(
                    "message" := apiMsg,
                    "post" := self
                      .confirmDownload(author, slug, target, Some(dlType.value), Some(token), None)
                      .absoluteURL(),
                    "url" := self.downloadJarById(project.pluginId, version.name, Some(token)).absoluteURL(),
                    "curl" := curlInstruction,
                    "token" := token
                  )
                  .spaces4
              ).withHeaders("Content-Disposition" -> "inline; filename=\"README.txt\"")
            )
          } else {
            val userAgent = request.headers.get("User-Agent").map(_.toLowerCase)

            if (userAgent.exists(_.startsWith("wget/"))) {
              IO.pure(
                MultipleChoices(this.messagesApi("version.download.confirm.wget"))
                  .withHeaders("Content-Disposition" -> "inline; filename=\"README.txt\"")
              )
            } else if (userAgent.exists(_.startsWith("curl/"))) {
              (removeWarnings *> addWarning).as(
                MultipleChoices(
                  apiMsg + "\n" + curlInstruction + "\n"
                ).withHeaders("Content-Disposition" -> "inline; filename=\"README.txt\"")
              )
            } else {
              version.channel.map(_.isNonReviewed).map { nonReviewed =>
                //We return Ok here to make sure Chrome sets the cookie
                //https://bugs.chromium.org/p/chromium/issues/detail?id=696204
                Ok(views.unsafeDownload(project, version, nonReviewed, dlType))
                  .addingToSession(DownloadWarning.cookieKey(version.id) -> "set")
              }
            }
          }
        }
    }
  }

  def confirmDownload(
      author: String,
      slug: String,
      target: String,
      downloadType: Option[Int],
      token: Option[String],
      dummy: Option[String] //A parameter to get around Chrome's cache
  ): Action[AnyContent] = {
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      getVersion(request.data.project, target)
        .ensure(Redirect(ShowProject(author, slug)).withError("error.plugin.stateChanged"))(
          _.reviewState != ReviewState.Reviewed
        )
        .flatMap { version =>
          confirmDownload0(version.id, downloadType, token)
            .toRight(Redirect(ShowProject(author, slug)).withError("error.plugin.noConfirmDownload"))
        }
        .map {
          case (dl, optNewSession) =>
            val newSession = optNewSession.getOrElse(request.session)
            dl.downloadType match {
              case DownloadType.UploadedFile =>
                Redirect(self.download(author, slug, target, token)).withSession(newSession)
              case DownloadType.JarFile =>
                Redirect(self.downloadJar(author, slug, target, token)).withSession(newSession)
            }
        }
    }
  }

  /**
    * Confirms the download and prepares the unsafe download.
    */
  private def confirmDownload0(versionId: DbRef[Version], downloadType: Option[Int], optToken: Option[String])(
      implicit request: OreRequest[_]
  ): OptionT[IO, (Model[UnsafeDownload], Option[play.api.mvc.Session])] = {
    val addr = InetString(StatTracker.remoteAddress)
    val dlType = downloadType
      .flatMap(DownloadType.withValueOpt)
      .getOrElse(DownloadType.UploadedFile)

    val user = request.currentUser

    val insertDownload = service.insert(
      UnsafeDownload(userId = user.map(_.id.value), address = addr, downloadType = dlType)
    )

    optToken match {
      case None =>
        val cookieKey    = DownloadWarning.cookieKey(versionId)
        val sessionIsSet = request.session.get(cookieKey).contains("set")

        if (sessionIsSet) {
          val newSession = request.session + (cookieKey -> "confirmed")
          OptionT.liftF(insertDownload.tupleRight(Some(newSession)))
        } else {
          OptionT.none[IO, (Model[UnsafeDownload], Option[play.api.mvc.Session])]
        }
      case Some(token) =>
        // find warning
        ModelView
          .now(DownloadWarning)
          .find { warn =>
            (warn.address === addr) &&
            (warn.token === token) &&
            (warn.versionId === versionId) &&
            !warn.isConfirmed &&
            warn.downloadId.?.isEmpty
          }
          .flatMapF { warn =>
            if (warn.hasExpired) service.delete(warn).as(None) else IO.pure(Some(warn))
          }
          .semiflatMap { warn =>
            // warning confirmed and redirect to download
            for {
              unsafeDownload <- insertDownload
              _              <- service.update(warn)(_.copy(isConfirmed = true, downloadId = Some(unsafeDownload.id)))
            } yield (unsafeDownload, None)
          }
    }
  }

  /**
    * Sends the specified project's current recommended version to the client.
    *
    * @param author Project owner
    * @param slug   Project slug
    * @return Sent file
    */
  def downloadRecommended(author: String, slug: String, token: Option[String]): Action[AnyContent] = {
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      request.project
        .recommendedVersion(ModelView.now(Version))
        .sequence
        .subflatMap(identity)
        .toRight(NotFound)
        .semiflatMap(sendVersion(request.project, _, token))
    }
  }

  /**
    * Downloads the specified version as a JAR regardless of the original
    * uploaded file type.
    *
    * @param author         Project owner
    * @param slug           Project slug
    * @param versionString  Version name
    * @return               Sent file
    */
  def downloadJar(author: String, slug: String, versionString: String, token: Option[String]): Action[AnyContent] =
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      getVersion(request.project, versionString).semiflatMap(sendJar(request.project, _, token))
    }

  private def sendJar(
      project: Model[Project],
      version: Model[Version],
      token: Option[String],
      api: Boolean = false
  )(
      implicit request: ProjectRequest[_]
  ): IO[Result] = {
    if (project.visibility == Visibility.SoftDelete) {
      IO.pure(NotFound)
    } else {
      checkConfirmation(version, token).flatMap { passed =>
        if (!passed) {
          IO.pure(
            Redirect(
              self.showDownloadConfirm(
                project.ownerName,
                project.slug,
                version.name,
                Some(DownloadType.JarFile.value),
                api = Some(api),
                None
              )
            )
          )
        } else {
          val fileName = version.fileName
          val path     = this.fileManager.getVersionDir(project.ownerName, project.name, version.name).resolve(fileName)
          project.user.flatMap { projectOwner =>
            this.stats.versionDownloaded(version) {
              if (fileName.endsWith(".jar"))
                IO.pure(Ok.sendPath(path))
              else {
                val pluginFile = new PluginFile(path, projectOwner)
                val jarName    = fileName.substring(0, fileName.lastIndexOf('.')) + ".jar"
                val jarPath    = env.tmp.resolve(project.ownerName).resolve(jarName)

                pluginFile
                  .newJarStream[IO]
                  .use { jarIn =>
                    jarIn
                      .fold(
                        e => IO.raiseError(new Exception(e)),
                        is => IO(copy(is, jarPath, StandardCopyOption.REPLACE_EXISTING))
                      )
                      .void
                  }
                  .onError {
                    case e => IO(MDCLogger.error("an error occurred while trying to send a plugin", e))
                  }
                  .as(Ok.sendPath(jarPath, onClose = () => Files.delete(jarPath)))
              }
            }
          }

        }
      }
    }

  }

  /**
    * Downloads the Project's recommended version as a JAR regardless of the
    * original uploaded file type.
    *
    * @param author Project owner
    * @param slug   Project slug
    * @return       Sent file
    */
  def downloadRecommendedJar(author: String, slug: String, token: Option[String]): Action[AnyContent] = {
    ProjectAction(author, slug).asyncEitherT { implicit request =>
      request.project
        .recommendedVersion(ModelView.now(Version))
        .sequence
        .subflatMap(identity)
        .toRight(NotFound)
        .semiflatMap(sendJar(request.project, _, token))
    }
  }

  /**
    * Downloads the specified version as a JAR regardless of the original
    * uploaded file type.
    *
    * @param pluginId       Project unique plugin ID
    * @param versionString  Version name
    * @return               Sent file
    */
  def downloadJarById(pluginId: String, versionString: String, optToken: Option[String]): Action[AnyContent] = {
    ProjectAction(pluginId).asyncEitherT { implicit request =>
      val project = request.project
      getVersion(project, versionString).semiflatMap { version =>
        optToken
          .map { token =>
            confirmDownload0(version.id, Some(DownloadType.JarFile.value), Some(token)).value *>
              sendJar(project, version, optToken, api = true)
          }
          .getOrElse(sendJar(project, version, optToken, api = true))
      }
    }
  }

  /**
    * Downloads the Project's recommended version as a JAR regardless of the
    * original uploaded file type.
    *
    * @param pluginId Project unique plugin ID
    * @return         Sent file
    */
  def downloadRecommendedJarById(pluginId: String, token: Option[String]): Action[AnyContent] = {
    ProjectAction(pluginId).asyncEitherT { implicit request =>
      val data = request.data
      request.project
        .recommendedVersion(ModelView.now(Version))
        .sequence
        .subflatMap(identity)
        .toRight(NotFound)
        .semiflatMap(sendJar(data.project, _, token, api = true))
    }
  }
}