MiniDigger/Hangar

View on GitHub
apiV2/app/controllers/apiv2/ApiV2Controller.scala

Summary

Maintainability
D
2 days
Test Coverage
package controllers.apiv2

import scala.language.higherKinds

import java.nio.file.Path
import java.time.{Instant, LocalDateTime, ZoneOffset}
import java.util.UUID
import javax.inject.{Inject, Singleton}

import scala.collection.immutable
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future}

import play.api.http.{HttpErrorHandler, Writeable}
import play.api.i18n.Lang
import play.api.libs.Files
import play.api.mvc._

import controllers.apiv2.ApiV2Controller._
import controllers.sugar.CircePlayController
import controllers.sugar.Requests.{ApiAuthInfo, ApiRequest}
import controllers.{OreBaseController, OreControllerComponents}
import db.impl.query.APIV2Queries
import models.protocols.APIV2
import models.querymodels.{APIV2QueryVersion, APIV2QueryVersionTag}
import ore.data.project.Category
import ore.db.impl.OrePostgresDriver.api._
import ore.db.impl.schema.{ApiKeyTable, OrganizationTable, ProjectTableMain}
import ore.db.{DbRef, Model}
import ore.models.api.ApiSession
import ore.models.project.factory.ProjectFactory
import ore.models.project.io.{PluginUpload, ProjectFiles}
import ore.models.project.{Page, ProjectSortingStrategy}
import ore.models.user.{FakeUser, User}
import ore.permission.scope.{GlobalScope, OrganizationScope, ProjectScope, Scope}
import ore.permission.{NamedPermission, Permission}
import ore.util.OreMDC
import _root_.util.IOUtils
import _root_.util.syntax._

import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials}
import akka.stream.Materializer
import akka.stream.scaladsl.FileIO
import akka.util.ByteString
import cats.data.{EitherT, NonEmptyList, OptionT}
import cats.effect.{IO, Sync}
import cats.syntax.all._
import com.typesafe.scalalogging
import enumeratum._
import io.circe._
import io.circe.generic.extras._
import io.circe.syntax._

@Singleton
class ApiV2Controller @Inject()(factory: ProjectFactory, val errorHandler: HttpErrorHandler, fakeUser: FakeUser)(
    implicit oreComponents: OreControllerComponents[IO],
    projectFiles: ProjectFiles,
    mat: Materializer
) extends OreBaseController
    with CircePlayController {

  private val Logger = scalalogging.Logger.takingImplicit[OreMDC]("ApiV2")

  private def limitOrDefault(limit: Option[Long], default: Long) = math.min(limit.getOrElse(default), default)
  private def offsetOrZero(offset: Long)                         = math.max(offset, 0)

  private def parseAuthHeader(request: Request[_]): EitherT[IO, Either[Unit, Result], HttpCredentials] = {
    lazy val authUrl                 = routes.ApiV2Controller.authenticate().absoluteURL()(request)
    def unAuth[A: Writeable](msg: A) = Unauthorized(msg).withHeaders(WWW_AUTHENTICATE -> authUrl)

    EitherT
      .fromOption[IO](request.headers.get(AUTHORIZATION), Left(()))
      .map(Authorization.parseFromValueString)
      .map(_.leftMap { es =>
        NonEmptyList
          .fromList(es)
          .fold(Right(unAuth(ApiError("Could not parse authorization header"))))(
            es2 => Right(unAuth(ApiErrors(es2.map(_.summary))))
          )
      })
      .subflatMap(identity)
      .map(_.credentials)
      .subflatMap { creds =>
        if (creds.scheme == "OreApi")
          Right(creds)
        else
          Left(Right(unAuth(ApiError("Invalid scheme for authorization. Needs to be OreApi"))))
      }
  }

  def apiAction: ActionRefiner[Request, ApiRequest] = new ActionRefiner[Request, ApiRequest] {
    def executionContext: ExecutionContext = ec
    override protected def refine[A](request: Request[A]): Future[Either[Result, ApiRequest[A]]] = {
      lazy val authUrl        = routes.ApiV2Controller.authenticate().absoluteURL()(request)
      def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> authUrl)

      parseAuthHeader(request)
        .leftMap(_.leftMap(_ => unAuth("No authorization specified")).merge)
        .flatMap(creds => EitherT.fromOption[IO](creds.params.get("session"), unAuth("No session specified")))
        .flatMap { token =>
          OptionT(service.runDbCon(APIV2Queries.getApiAuthInfo(token).option))
            .toRight(unAuth("Invalid session"))
            .flatMap { info =>
              if (info.expires.isBefore(Instant.now())) {
                EitherT
                  .left[ApiAuthInfo](service.deleteWhere(ApiSession)(_.token === token))
                  .leftMap(_ => unAuth("Api session expired"))
              } else EitherT.rightT[IO, Result](info)
            }
            .map(info => ApiRequest(info, request))
        }
        .value
        .unsafeToFuture()
    }
  }

  def apiScopeToRealScope(scope: APIScope): OptionT[IO, Scope] = scope match {
    case APIScope.GlobalScope => OptionT.pure[IO](GlobalScope)
    case APIScope.ProjectScope(pluginId) =>
      OptionT(
        service.runDBIO(TableQuery[ProjectTableMain].filter(_.pluginId === pluginId).map(_.id).result.headOption)
      ).map(id => ProjectScope(id))
    case APIScope.OrganizationScope(organizationName) =>
      OptionT(
        service.runDBIO(
          TableQuery[OrganizationTable].filter(_.name === organizationName).map(_.id).result.headOption
        )
      ).map(id => OrganizationScope(id))
  }

  def permApiAction(perms: Permission, scope: APIScope): ActionFilter[ApiRequest] = new ActionFilter[ApiRequest] {
    override protected def executionContext: ExecutionContext = ec

    override protected def filter[A](request: ApiRequest[A]): Future[Option[Result]] = {
      //Techically we could make this faster by first checking if the global perms have the needed perms,
      //but then we wouldn't get the 404 on a non existent scope.
      val scopePerms = apiScopeToRealScope(scope).semiflatMap(request.permissionIn(_))

      scopePerms.toRight(NotFound).ensure(Forbidden)(_.has(perms)).swap.toOption.value.unsafeToFuture()
    }
  }

  def ApiAction(perms: Permission, scope: APIScope): ActionBuilder[ApiRequest, AnyContent] =
    Action.andThen(apiAction).andThen(permApiAction(perms, scope))

  def apiDbAction[A: Encoder](perms: Permission, scope: APIScope)(
      action: ApiRequest[AnyContent] => doobie.ConnectionIO[A]
  ): Action[AnyContent] =
    ApiAction(perms, scope).asyncF { request =>
      service.runDbCon(action(request)).map(a => Ok(a.asJson))
    }

  def apiOptDbAction[A: Encoder](perms: Permission, scope: APIScope)(
      action: ApiRequest[AnyContent] => doobie.ConnectionIO[Option[A]]
  ): Action[AnyContent] =
    ApiAction(perms, scope).asyncF { request =>
      service.runDbCon(action(request)).map(_.fold(NotFound: Result)(a => Ok(a.asJson)))
    }

  def apiEitherDbAction[A: Encoder](perms: Permission, scope: APIScope)(
      action: ApiRequest[AnyContent] => Either[Result, doobie.ConnectionIO[A]]
  ): Action[AnyContent] =
    ApiAction(perms, scope).asyncF { request =>
      action(request).bimap(IO.pure, service.runDbCon(_).map(a => Ok(a.asJson))).merge
    }

  def apiEitherVecDbAction[A: Encoder](perms: Permission, scope: APIScope)(
      action: ApiRequest[AnyContent] => Either[Result, doobie.ConnectionIO[Vector[A]]]
  ): Action[AnyContent] =
    ApiAction(perms, scope).asyncF { request =>
      action(request).bimap(IO.pure, service.runDbCon).map(_.map(a => Ok(a.asJson))).merge
    }

  def apiVecDbAction[A: Encoder](
      perms: Permission,
      scope: APIScope
  )(action: ApiRequest[AnyContent] => doobie.ConnectionIO[Vector[A]]): Action[AnyContent] =
    ApiAction(perms, scope).asyncF { request =>
      service.runDbCon(action(request)).map(xs => Ok(xs.asJson))
    }

  private def expiration(duration: FiniteDuration) = Instant.now().plusSeconds(duration.toSeconds)

  def authenticateUser(): Action[AnyContent] = Authenticated.asyncF { implicit request =>
    val sessionExpiration = expiration(config.ore.api.session.expiration)
    val uuidToken         = UUID.randomUUID().toString
    val sessionToInsert   = ApiSession(uuidToken, None, Some(request.user.id), sessionExpiration)

    service.insert(sessionToInsert).map { key =>
      Ok(
        ReturnedApiSession(
          key.token,
          LocalDateTime.ofInstant(key.expires, ZoneOffset.UTC),
          SessionType.User
        )
      )
    }
  }

  private val uuidRegex = """[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}"""
  private val ApiKeyRegex =
    s"""($uuidRegex).($uuidRegex)""".r

  def authenticateKeyPublic(): Action[AnyContent] = Action.asyncEitherT { implicit request =>
    lazy val sessionExpiration       = expiration(config.ore.api.session.expiration)
    lazy val publicSessionExpiration = expiration(config.ore.api.session.publicExpiration)

    lazy val authUrl        = routes.ApiV2Controller.authenticate().absoluteURL()(request)
    def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> authUrl)

    val uuidToken = UUID.randomUUID().toString

    val sessionToInsert2 = parseAuthHeader(request)
      .flatMap[Either[Unit, Result], (SessionType, ApiSession)] { creds =>
        creds.params.get("apikey") match {
          case Some(ApiKeyRegex(identifier, token)) =>
            OptionT(service.runDbCon(APIV2Queries.findApiKey(identifier, token).option))
              .toRight(Right(unAuth("Invalid api key")): Either[Unit, Result])
              .map {
                case (keyId, keyOwnerId) =>
                  SessionType.Key -> ApiSession(uuidToken, Some(keyId), Some(keyOwnerId), sessionExpiration)
              }
          case _ =>
            EitherT.leftT[IO, (SessionType, ApiSession)](
              Right(unAuth("No apikey parameter found in Authorization")): Either[Unit, Result]
            )
        }
      }
      .leftFlatMap[(SessionType, ApiSession), Result] {
        case Left(_) =>
          EitherT.rightT[IO, Result](SessionType.Public -> ApiSession(uuidToken, None, None, publicSessionExpiration))
        case Right(e) => EitherT.leftT[IO, (SessionType, ApiSession)](e)
      }

    sessionToInsert2
      .semiflatMap(t => service.insert(t._2).tupleLeft(t._1))
      .map {
        case (tpe, key) =>
          Ok(
            ReturnedApiSession(
              key.token,
              LocalDateTime.ofInstant(key.expires, ZoneOffset.UTC),
              tpe
            )
          )
      }
  }

  def authenticateDev(): Action[AnyContent] = Action.asyncF {
    if (fakeUser.isEnabled) {
      config.checkDebug()

      val sessionExpiration = expiration(config.ore.api.session.expiration)
      val uuidToken         = UUID.randomUUID().toString
      val sessionToInsert   = ApiSession(uuidToken, None, Some(fakeUser.id), sessionExpiration)

      service.insert(sessionToInsert).map { key =>
        Ok(
          ReturnedApiSession(
            key.token,
            LocalDateTime.ofInstant(key.expires, ZoneOffset.UTC),
            SessionType.Dev
          )
        )
      }
    } else {
      IO.pure(Forbidden)
    }
  }

  def authenticate(fake: Boolean): Action[AnyContent] = if (fake) authenticateDev() else authenticateKeyPublic()

  def createKey(): Action[KeyToCreate] =
    ApiAction(Permission.EditApiKeys, APIScope.GlobalScope)(parseCirce.decodeJson[KeyToCreate]).asyncF {
      implicit request =>
        val permsVal = NamedPermission.parseNamed(request.body.permissions).toValidNel("Invalid permission name")
        val nameVal  = Some(request.body.name).filter(_.nonEmpty).toValidNel("Name was empty")

        (permsVal, nameVal)
          .mapN { (perms, name) =>
            val perm     = Permission(perms.map(_.permission): _*)
            val isSubKey = request.apiInfo.key.forall(_.isSubKey(perm))

            if (!isSubKey) {
              IO.pure(BadRequest(ApiError("Not enough permissions to create that key")))
            } else {
              val tokenIdentifier = UUID.randomUUID().toString
              val token           = UUID.randomUUID().toString
              val ownerId         = request.user.get.id.value

              val nameTaken =
                TableQuery[ApiKeyTable].filter(t => t.name === name && t.ownerId === ownerId).exists.result

              val ifTaken = IO.pure(Conflict(ApiError("Name already taken")))
              val ifFree = service
                .runDbCon(APIV2Queries.createApiKey(name, ownerId, tokenIdentifier, token, perm).run)
                .map(_ => Ok(CreatedApiKey(s"$tokenIdentifier.$token", perm.toNamedSeq)))

              service.runDBIO(nameTaken).ifM(ifTaken, ifFree)
            }
          }
          .leftMap((ApiErrors.apply _).andThen(BadRequest.apply(_)).andThen(IO.pure(_)))
          .merge
    }

  def deleteKey(name: String): Action[AnyContent] =
    ApiAction(Permission.EditApiKeys, APIScope.GlobalScope).asyncEitherT { implicit request =>
      EitherT
        .fromOption[IO](request.user, BadRequest(ApiError("Public keys can't be used to delete")))
        .semiflatMap { user =>
          service.runDbCon(APIV2Queries.deleteApiKey(name, user.id.value).run).map {
            case 0 => NotFound: Result
            case _ => NoContent: Result
          }
        }
    }

  def createApiScope(pluginId: Option[String], organizationName: Option[String]): Either[Result, APIScope] =
    (pluginId, organizationName) match {
      case (Some(_), Some(_)) =>
        Left(BadRequest(ApiError("Can't check for project and organization permissions at the same time")))
      case (Some(plugId), None)  => Right(APIScope.ProjectScope(plugId))
      case (None, Some(orgName)) => Right(APIScope.OrganizationScope(orgName))
      case (None, None)          => Right(APIScope.GlobalScope)
    }

  def permissionsInCreatedApiScope(pluginId: Option[String], organizationName: Option[String])(
      implicit request: ApiRequest[_]
  ): EitherT[IO, Result, (APIScope, Permission)] =
    EitherT
      .fromEither[IO](createApiScope(pluginId, organizationName))
      .flatMap(t => apiScopeToRealScope(t).tupleLeft(t).toRight(NotFound: Result))
      .semiflatMap(t => request.permissionIn(t._2).tupleLeft(t._1))

  def showPermissions(pluginId: Option[String], organizationName: Option[String]): Action[AnyContent] =
    ApiAction(Permission.None, APIScope.GlobalScope).asyncEitherT { implicit request =>
      permissionsInCreatedApiScope(pluginId, organizationName).map {
        case (scope, perms) =>
          Ok(
            KeyPermissions(
              scope.tpe,
              perms.toNamedSeq.toList
            )
          )
      }
    }

  def has(permissions: Seq[NamedPermission], pluginId: Option[String], organizationName: Option[String])(
      check: (Seq[NamedPermission], Permission) => Boolean
  ): Action[AnyContent] =
    ApiAction(Permission.None, APIScope.GlobalScope).asyncEitherT { implicit request =>
      permissionsInCreatedApiScope(pluginId, organizationName).map {
        case (scope, perms) =>
          Ok(PermissionCheck(scope.tpe, check(permissions, perms)))
      }
    }

  def hasAll(
      permissions: Seq[NamedPermission],
      pluginId: Option[String],
      organizationName: Option[String]
  ): Action[AnyContent] =
    has(permissions, pluginId, organizationName)((seq, perm) => seq.forall(p => perm.has(p.permission)))

  def hasAny(
      permissions: Seq[NamedPermission],
      pluginId: Option[String],
      organizationName: Option[String]
  ): Action[AnyContent] =
    has(permissions, pluginId, organizationName)((seq, perm) => seq.exists(p => perm.has(p.permission)))

  def listProjects(
      q: Option[String],
      categories: Seq[Category],
      tags: Seq[String],
      owner: Option[String],
      sort: Option[ProjectSortingStrategy],
      relevance: Option[Boolean],
      limit: Option[Long],
      offset: Long
  ): Action[AnyContent] =
    ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { implicit request =>
      val realLimit  = limitOrDefault(limit, config.ore.projects.initLoad)
      val realOffset = offsetOrZero(offset)
      val getProjects = APIV2Queries
        .projectQuery(
          None,
          categories.toList,
          tags.toList,
          q,
          owner,
          request.globalPermissions.has(Permission.SeeHidden),
          request.user.map(_.id),
          sort.getOrElse(ProjectSortingStrategy.Default),
          relevance.getOrElse(true),
          realLimit,
          realOffset
        )
        .to[Vector]

      val countProjects = APIV2Queries
        .projectCountQuery(
          None,
          categories.toList,
          tags.toList,
          q,
          owner,
          request.globalPermissions.has(Permission.SeeHidden),
          request.user.map(_.id)
        )
        .unique

      (service.runDbCon(getProjects), service.runDbCon(countProjects)).parMapN { (projects, count) =>
        Ok(
          PaginatedProjectResult(
            Pagination(realLimit, realOffset, count),
            projects
          )
        )
      }
    }

  def showProject(pluginId: String): Action[AnyContent] =
    apiOptDbAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)) { implicit request =>
      APIV2Queries
        .projectQuery(
          Some(pluginId),
          Nil,
          Nil,
          None,
          None,
          request.globalPermissions.has(Permission.SeeHidden),
          request.user.map(_.id),
          ProjectSortingStrategy.Default,
          orderWithRelevance = false,
          1,
          0
        )
        .option
    }

  def showMembers(pluginId: String, limit: Option[Long], offset: Long): Action[AnyContent] =
    apiVecDbAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)) { _ =>
      APIV2Queries
        .projectMembers(pluginId, limitOrDefault(limit, 25), offsetOrZero(offset))
        .to[Vector]
    }

  def listVersions(
      pluginId: String,
      tags: Seq[String],
      limit: Option[Long],
      offset: Long
  ): Action[AnyContent] =
    ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)).asyncF {
      val realLimit  = limitOrDefault(limit, config.ore.projects.initVersionLoad.toLong)
      val realOffset = offsetOrZero(offset)
      val getVersions = APIV2Queries
        .versionQuery(
          pluginId,
          None,
          tags.toList,
          realLimit,
          realOffset
        )
        .to[Vector]

      val countVersions = APIV2Queries.versionCountQuery(pluginId, tags.toList).unique

      (service.runDbCon(getVersions), service.runDbCon(countVersions)).parMapN { (versions, count) =>
        Ok(
          PaginatedVersionResult(
            Pagination(realLimit, realOffset, count),
            versions
          )
        )
      }
    }

  def showVersion(pluginId: String, name: String): Action[AnyContent] =
    apiOptDbAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)) { _ =>
      APIV2Queries.versionQuery(pluginId, Some(name), Nil, 1, 0).option
    }

  //Not sure if FileIO us AsynchronousFileChannel, if it doesn't we can fix this later if it becomes a problem
  private def readFileAsync(file: Path): IO[String] =
    IO.fromFuture(IO(FileIO.fromPath(file).fold(ByteString.empty)(_ ++ _).map(_.utf8String).runFold("")(_ + _)))

  def deployVersion(pluginId: String): Action[MultipartFormData[Files.TemporaryFile]] =
    ApiAction(Permission.CreateVersion, APIScope.ProjectScope(pluginId))(parse.multipartFormData).asyncEitherT {
      implicit request =>
        type TempFile = MultipartFormData.FilePart[Files.TemporaryFile]

        val acquire = OptionT(IO(request.body.file("plugin-info")))
        val use     = (filePart: TempFile) => OptionT.liftF(readFileAsync(filePart.ref))
        val release = (filePart: TempFile) =>
          OptionT.liftF(
            IO(java.nio.file.Files.deleteIfExists(filePart.ref))
              .runAsync(IOUtils.logCallback("Error deleting file upload", Logger))
              .toIO
        )

        val pluginInfoFromFileF = Sync.catsOptionTSync[IO].bracket(acquire)(use)(release)

        val fileF = EitherT.fromEither[IO](
          request.body.file("plugin-file").toRight(BadRequest(ApiError("No plugin file specified")))
        )
        val dataF = OptionT
          .fromOption[IO](request.body.dataParts.get("plugin-info").flatMap(_.headOption))
          .orElse(pluginInfoFromFileF)
          .toRight("No or invalid plugin info specified")
          .subflatMap(s => parser.decode[DeployVersionInfo](s).leftMap(_.show))
          .ensure("Description too long")(_.description.forall(_.length > Page.maxLength))
          .leftMap(e => BadRequest(ApiError(e)))

        def uploadErrors(user: Model[User]) = {
          implicit val lang: Lang = user.langOrDefault
          EitherT.fromEither[IO](
            factory
              .getUploadError(user)
              .map(e => BadRequest(UserError(messagesApi(e))))
              .toLeft(())
          )
        }

        for {
          user            <- EitherT.fromOption[IO](request.user, BadRequest(ApiError("No user found for session")))
          _               <- uploadErrors(user)
          project         <- projects.withPluginId(pluginId).toRight(NotFound: Result)
          projectSettings <- EitherT.right[Result](project.settings)
          data            <- dataF
          file            <- fileF
          pendingVersion <- factory
            .processSubsequentPluginUpload(PluginUpload(file.ref, file.filename), user, project)
            .leftMap { s =>
              implicit val lang: Lang = user.langOrDefault
              BadRequest(UserError(messagesApi(s)))
            }
            .map { v =>
              v.copy(
                createForumPost = data.create_forum_post.getOrElse(projectSettings.forumSync),
                channelName = data.tags.getOrElse("Channel", v.channelName),
                description = data.description
              )
            }
          t <- EitherT.right[Result](pendingVersion.complete(project, factory))
          (project, version, channel, tags) = t
          _ <- EitherT.right[Result](
            if (data.recommended.exists(identity))
              service.update(project)(_.copy(recommendedVersionId = Some(version.id)))
            else IO.unit
          )
        } yield {
          val normalApiTags = tags.map(tag => APIV2QueryVersionTag(tag.name, tag.data, tag.color)).toList
          val channelApiTag = APIV2QueryVersionTag(
            "Channel",
            channel.name,
            channel.color.toTagColor
          )
          val apiTags = channelApiTag :: normalApiTags
          val apiVersion = APIV2QueryVersion(
            LocalDateTime.ofInstant(version.createdAt, ZoneOffset.UTC),
            version.versionString,
            version.dependencyIds,
            version.visibility,
            version.description,
            version.downloadCount,
            version.fileSize,
            version.hash,
            version.fileName,
            Some(user.name),
            version.reviewState,
            apiTags
          )

          Created(apiVersion.asProtocol)
        }
    }

  def showUser(user: String): Action[AnyContent] =
    apiOptDbAction(Permission.ViewPublicInfo, APIScope.GlobalScope)(_ => APIV2Queries.userQuery(user).option)

  def showStarred(
      user: String,
      sort: Option[ProjectSortingStrategy],
      limit: Option[Long],
      offset: Long
  ): Action[AnyContent] =
    showUserAction(user, sort, limit, offset, APIV2Queries.starredQuery, APIV2Queries.starredCountQuery)

  def showWatching(
      user: String,
      sort: Option[ProjectSortingStrategy],
      limit: Option[Long],
      offset: Long
  ): Action[AnyContent] =
    showUserAction(user, sort, limit, offset, APIV2Queries.watchingQuery, APIV2Queries.watchingCountQuery)

  def showUserAction(
      user: String,
      sort: Option[ProjectSortingStrategy],
      limit: Option[Long],
      offset: Long,
      query: (
          String,
          Boolean,
          Option[DbRef[User]],
          ProjectSortingStrategy,
          Long,
          Long
      ) => doobie.Query0[APIV2.CompactProject],
      countQuery: (String, Boolean, Option[DbRef[User]]) => doobie.Query0[Long]
  ): Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { request =>
    val realLimit = limitOrDefault(limit, config.ore.projects.initLoad)

    val getProjects = query(
      user,
      request.globalPermissions.has(Permission.SeeHidden),
      request.user.map(_.id),
      sort.getOrElse(ProjectSortingStrategy.Default),
      realLimit,
      offset
    ).to[Vector]

    val countProjects = countQuery(
      user,
      request.globalPermissions.has(Permission.SeeHidden),
      request.user.map(_.id)
    ).unique

    (service.runDbCon(getProjects), service.runDbCon(countProjects)).parMapN { (projects, count) =>
      Ok(
        PaginatedCompactProjectResult(
          Pagination(realLimit, offset, count),
          projects
        )
      )
    }
  }
}
object ApiV2Controller {

  import APIV2.config

  sealed abstract class APIScope(val tpe: APIScopeType)
  object APIScope {
    case object GlobalScope                                extends APIScope(APIScopeType.Global)
    case class ProjectScope(pluginId: String)              extends APIScope(APIScopeType.Project)
    case class OrganizationScope(organizationName: String) extends APIScope(APIScopeType.Organization)
  }

  sealed abstract class APIScopeType extends EnumEntry with EnumEntry.Snakecase
  object APIScopeType extends Enum[APIScopeType] {
    case object Global       extends APIScopeType
    case object Project      extends APIScopeType
    case object Organization extends APIScopeType

    val values: immutable.IndexedSeq[APIScopeType] = findValues

    implicit val encoder: Encoder[APIScopeType] = APIV2.enumEncoder(APIScopeType)(_.entryName)
    implicit val decoder: Decoder[APIScopeType] = APIV2.enumDecoder(APIScopeType)(_.entryName)
  }

  sealed abstract class SessionType extends EnumEntry with EnumEntry.Snakecase
  object SessionType extends Enum[SessionType] {
    case object Key    extends SessionType
    case object User   extends SessionType
    case object Public extends SessionType
    case object Dev    extends SessionType

    val values: immutable.IndexedSeq[SessionType] = findValues

    implicit val encoder: Encoder[SessionType] = APIV2.enumEncoder(SessionType)(_.entryName)
    implicit val decoder: Decoder[SessionType] = APIV2.enumDecoder(SessionType)(_.entryName)
  }

  @ConfiguredJsonCodec case class ApiError(error: String)
  @ConfiguredJsonCodec case class ApiErrors(errors: NonEmptyList[String])
  @ConfiguredJsonCodec case class UserError(user_error: String)

  @ConfiguredJsonCodec case class KeyToCreate(name: String, permissions: Seq[String])
  @ConfiguredJsonCodec case class CreatedApiKey(key: String, perms: Seq[NamedPermission])

  @ConfiguredJsonCodec case class DeployVersionInfo(
      recommended: Option[Boolean],
      create_forum_post: Option[Boolean],
      description: Option[String],
      tags: Map[String, String]
  )

  @ConfiguredJsonCodec case class ReturnedApiSession(
      session: String,
      expires: LocalDateTime,
      @JsonKey("type") tpe: SessionType
  )

  @ConfiguredJsonCodec case class PaginatedProjectResult(
      pagination: Pagination,
      result: Seq[APIV2.Project]
  )

  @ConfiguredJsonCodec case class PaginatedCompactProjectResult(
      pagination: Pagination,
      result: Seq[APIV2.CompactProject]
  )

  @ConfiguredJsonCodec case class PaginatedVersionResult(
      pagination: Pagination,
      result: Seq[APIV2.Version]
  )

  @ConfiguredJsonCodec case class Pagination(
      limit: Long,
      offset: Long,
      count: Long
  )

  implicit val namedPermissionEncoder: Encoder[NamedPermission] = APIV2.enumEncoder(NamedPermission)(_.entryName)
  implicit val namedPermissionDecoder: Decoder[NamedPermission] = APIV2.enumDecoder(NamedPermission)(_.entryName)

  @ConfiguredJsonCodec case class KeyPermissions(
      @JsonKey("type") tpe: APIScopeType,
      permissions: List[NamedPermission]
  )

  @ConfiguredJsonCodec case class PermissionCheck(
      @JsonKey("type") tpe: APIScopeType,
      result: Boolean
  )
}