diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 017c6a5f2..59a9c14b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,13 +9,14 @@ jobs: strategy: matrix: - java: [1.8, 1.11] + java: [8, 11, 17] env: PGPORT: 5432 DB_USERNAME: postgres DB_PASSWORD: postgres DB_DATABASE: postgres + client-dir: ./oreClient services: postgres: @@ -31,38 +32,21 @@ jobs: - 5432:5432 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up NodeJS uses: actions/setup-node@v3 with: node-version: 16.16.0 + cache: 'yarn' + cache-dependency-path: ${{env.client-dir}}/yarn.lock - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: + distribution: temurin java-version: ${{ matrix.java }} - - - name: Cache sbt Ivy cache - uses: actions/cache@v1 - with: - path: ~/.ivy2/cache - key: ${{ runner.os }}-sbt-ivy-cache-${{ hashFiles('**.sbt', 'project/**/*.scala') }} - restore-keys: ${{ runner.os }}-sbt-ivy-cache - - - name: Cache sbt Coursier cache - uses: actions/cache@v1 - with: - path: ~/.cache/coursier - key: ${{ runner.os }}-sbt-coursier-cache-${{ hashFiles('**.sbt', 'project/**/*.scala') }} - restore-keys: ${{ runner.os }}-sbt-coursier-cache - - - name: Cache sbt - uses: actions/cache@v1 - with: - path: ~/.sbt - key: ${{ runner.os }}-sbt-${{ hashFiles('**.sbt', 'project/**/*.scala', 'project/build.properties') }} - restore-keys: ${{ runner.os }}-sbt + cache: sbt - name: Initialize postgres extensions env: @@ -75,6 +59,36 @@ jobs: run: | cp ore/conf/application.conf.template ore/conf/application.conf cp jobs/src/main/resources/application.conf.template jobs/src/main/resources/application.conf + cp oreClient/src/main/assets/config.json5.template oreClient/src/main/assets/config.json5 - name: Run compile run: sbt "oreAll/compile;ore/test;ore/assets" + + lintClient: + runs-on: ubuntu-latest + + strategy: + matrix: + node: [14, 16] + + env: + client-dir: ./oreClient + + name: Client Linting (Node ${{ matrix.node }}) + steps: + - uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: 'yarn' + cache-dependency-path: ${{env.client-dir}}/yarn.lock + + - name: Install dependencies + run: yarn install + working-directory: ${{env.client-dir}} + + - name: Run linting + run: yarn run lint + working-directory: ${{env.client-dir}} diff --git a/.github/workflows/scalafmt.yml b/.github/workflows/scalafmt.yml index c3e35131f..d9b6e22ed 100644 --- a/.github/workflows/scalafmt.yml +++ b/.github/workflows/scalafmt.yml @@ -1,38 +1,38 @@ -name: Scalafmt Check - -on: - push: - paths: - - '**.scala' - - '**.sc' - - '**.sbt' - pull_request: - paths: - - '**.scala' - - '**.sc' - - '**.sbt' - -jobs: - check: - - runs-on: ubuntu-latest - - env: - VERSION: 2.4.2 - - steps: - - uses: actions/checkout@v2 - - #- name: Cache Scalafmt native image - # id: cache - # uses: actions/cache@v1 - # with: - # path: scalafmt-native - # key: ${{ runner.os }}-scalafmt-native-image-${{ hashFiles('scalafmt-native') }} - - #- name: Download Scalafmt-native - # if: steps.cache.outputs.cache-hit != 'true' - # run: curl https://raw.githubusercontent.com/scalameta/scalafmt/master/bin/install-scalafmt-native.sh | bash -s -- $VERSION $GITHUB_WORKSPACE/scalafmt-native - - - name: Check formatted +name: Scalafmt Check + +on: + push: + paths: + - '**.scala' + - '**.sc' + - '**.sbt' + pull_request: + paths: + - '**.scala' + - '**.sc' + - '**.sbt' + +jobs: + check: + + runs-on: ubuntu-latest + + env: + VERSION: 2.4.2 + + steps: + - uses: actions/checkout@v4 + + #- name: Cache Scalafmt native image + # id: cache + # uses: actions/cache@v1 + # with: + # path: scalafmt-native + # key: ${{ runner.os }}-scalafmt-native-image-${{ hashFiles('scalafmt-native') }} + + #- name: Download Scalafmt-native + # if: steps.cache.outputs.cache-hit != 'true' + # run: curl https://raw.githubusercontent.com/scalameta/scalafmt/master/bin/install-scalafmt-native.sh | bash -s -- $VERSION $GITHUB_WORKSPACE/scalafmt-native + + - name: Check formatted run: ./scalafmt --check --non-interactive \ No newline at end of file diff --git a/.gitignore b/.gitignore index d9ed06f08..ae10b3e77 100644 --- a/.gitignore +++ b/.gitignore @@ -183,6 +183,8 @@ local.properties node_modules # Ore +oreClient/src/main/assets/config.json5 +oreClient/dist jobs/src/main/resources/application.conf ore/conf/application.conf /project/project/target @@ -191,6 +193,7 @@ RUNNING_PID gradle .gradle build +user.sbt # Hydra project/hydra.sbt diff --git a/README.md b/README.md index 74de4b94b..4c04ea849 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ In a typical development environment, most of the defaults are fine. Here are a For `ore`: * You can disable authentication by setting `application.fakeUser` to `true`. +You also need to create a copy of `oreClient/src/main/resources/assets/config.json5.template` named `config.json5`. Try to mirror the values you used in the `application.conf` file here. + ## Running Running Ore is relatively simple. @@ -61,3 +63,39 @@ For `jobs`: more stack size to sbt in the way you're starting sbt. `-Xss4m` should be enough. If you're using IntelliJ, you can set this in the VM arguments field. If you're invoking sbt directly, the most common ways to set this is either through the `SBT_OPTS` environment variable, or with a file named `.jvmopts` with each flag on a new line. + +### Running with Webpack dev server +Play can be a bit slow to reload the application sometimes. Therefor it can be nice to instead use webpack's hot module +replacement. To do so, there are a few extra steps you need to go through. + +1. Comment out the line with `webpackMonitoredDirectories` in `build.sbt` (not needed, just for sanity with reloading) +2. Add this to your `application.conf` under the filters section +``` +cors { + allowedOrigins = ["http://localhost:8080"] +} +``` +3. If you want to see a logged in view, set `alwaysTryLogin` to `true` in `config.json5` +4. Start Ore like normal +5. Use `yarn run start` to start the webpack dev server +6. Navigate to `http://localhost:8080` + +#### Disable webpack monitoring +By default, Ore will monitor the frontend for changes and rebuilt it whenever it sees any. If you're running Ore with +the webpack server, this can be more of a hinderance. If you wish to disable it, add this to a new file +called `user.sbt` in the root folder. +```scala +lazy val setNoMonitoredFiles: State => State = { s: State => + val projectID = "oreClient" + val value = Nil + if (Project.extract(s).get(LocalProject(projectID) / Assets / webpackMonitoredDirectories) == value) + s + else + s"""set (LocalProject("$projectID") / Assets / webpackMonitoredDirectories) := $value""" :: s +} + +Global / onLoad := { + val old = (Global / onLoad).value + setNoMonitoredFiles compose old +} +``` diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala new file mode 100644 index 000000000..e54e554ff --- /dev/null +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -0,0 +1,172 @@ +package controllers.apiv2 + +import java.time.OffsetDateTime + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} + +import play.api.inject.ApplicationLifecycle +import play.api.mvc._ + +import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} +import controllers.sugar.CircePlayController +import controllers.sugar.Requests.ApiRequest +import controllers.{OreBaseController, OreControllerComponents} +import db.impl.query.apiv2.AuthQueries +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.api.ApiSession +import ore.permission.Permission +import ore.permission.scope.Scope + +import akka.http.scaladsl.model.ErrorInfo +import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials} +import cats.data.NonEmptyList +import cats.syntax.all._ +import zio.interop.catz._ +import zio.{IO, Task, ZIO} + +abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( + implicit oreComponents: OreControllerComponents +) extends OreBaseController + with CircePlayController { + + implicit def zioMode[R]: scalacache.Mode[ZIO[R, Throwable, *]] = + scalacache.CatsEffect.modes.async[ZIO[R, Throwable, *]] + + private val actionResultCache = scalacache.caffeine.CaffeineCache[Future[Result]] + lifecycle.addStopHook(() => zioRuntime.unsafeRunToFuture(actionResultCache.close[Task]())) + + protected def limitOrDefault(limit: Option[Long], default: Long): Long = math.min(limit.getOrElse(default), default) + protected def offsetOrZero(offset: Long): Long = math.max(offset, 0) + + sealed trait ParseAuthHeaderError { + private def unAuth(firstError: String, otherErrors: String*) = { + val res = + if (otherErrors.isEmpty) Unauthorized(ApiError(firstError)) + else Unauthorized(ApiErrors(NonEmptyList.of(firstError, otherErrors: _*))) + + res.withHeaders(WWW_AUTHENTICATE -> "OreApi") + } + + import ParseAuthHeaderError._ + def toResult: Result = this match { + case NoAuthHeader => unAuth("No authorization specified") + case UnparsableHeader => unAuth("Could not parse authorization header") + case ErrorParsingHeader(errors) => unAuth(errors.head.summary, errors.tail.map(_.summary): _*) + case InvalidScheme => unAuth("Invalid scheme for authorization. Needs to be OreApi") + } + } + object ParseAuthHeaderError { + case object NoAuthHeader extends ParseAuthHeaderError + case object UnparsableHeader extends ParseAuthHeaderError + case class ErrorParsingHeader(errors: NonEmptyList[ErrorInfo]) extends ParseAuthHeaderError + case object InvalidScheme extends ParseAuthHeaderError + } + + protected def parseAuthHeader(request: Request[_]): IO[ParseAuthHeaderError, HttpCredentials] = { + import ParseAuthHeaderError._ + + for { + stringAuth <- ZIO.fromOption(request.headers.get(AUTHORIZATION)).orElseFail(NoAuthHeader) + parsedAuth = Authorization + .parseFromValueString(stringAuth) + .leftMap(NonEmptyList.fromList(_).fold[ParseAuthHeaderError](UnparsableHeader)(ErrorParsingHeader)) + auth <- ZIO.fromEither(parsedAuth) + creds = auth.credentials + res <- { + if (creds.scheme == "OreApi") + ZIO.succeed(creds) + else + ZIO.fail(InvalidScheme) + } + } yield res + } + + def apiAction[S <: Scope](scope: APIScope[S]): ActionRefiner[Request, ApiRequest[S, *]] = + new ActionRefiner[Request, ApiRequest[S, *]] { + def executionContext: ExecutionContext = ec + + override protected def refine[A](request: Request[A]): Future[Either[Result, ApiRequest[S, A]]] = { + def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> "OreApi") + + val authRequest = for { + creds <- parseAuthHeader(request).mapError(_.toResult) + token <- ZIO + .fromOption(creds.params.get("session")) + .orElseFail(unAuth("No session specified")) + info <- service + .runDbCon(AuthQueries.getApiAuthInfo(token).option) + .get + .orElseFail(unAuth("Invalid session")) + realScope <- scope.toRealScope.orElseFail(NotFound) + scopePerms <- info.permissionIn(realScope) + res <- { + if (info.expires.isBefore(OffsetDateTime.now())) { + service.deleteWhere(ApiSession)(_.token === token) *> IO.fail(unAuth("Api session expired")) + } else ZIO.succeed(ApiRequest(info, scopePerms, realScope, request)) + } + } yield res + + zioToFuture(authRequest.either) + } + } + + def createApiScope( + projectOwner: Option[String], + projectSlug: Option[String], + organizationName: Option[String] + ): Either[Result, APIScope[_ <: Scope]] = { + val projectOwnerName = projectOwner.zip(projectSlug) + + if ((projectOwner.isDefined || projectSlug.isDefined) && projectOwnerName.isEmpty) { + Left(BadRequest(ApiError("You need to specify both the project owner and slug at the same time, not just one"))) + } else { + (projectOwnerName, organizationName) match { + case (Some(_), Some(_)) => + Left(BadRequest(ApiError("Can't check for project and organization permissions at the same time"))) + case (Some((owner, name)), None) => Right(APIScope.ProjectScope(owner, name)) + case (None, Some(orgName)) => Right(APIScope.OrganizationScope(orgName)) + case (None, None) => Right(APIScope.GlobalScope) + } + } + } + + def permApiAction[S <: Scope](perms: Permission): ActionFilter[ApiRequest[S, *]] = + new ActionFilter[ApiRequest[S, *]] { + override protected def executionContext: ExecutionContext = ec + + override protected def filter[A](request: ApiRequest[S, A]): Future[Option[Result]] = + if (request.scopePermission.has(perms)) Future.successful(None) + else Future.successful(Some(Forbidden)) + } + + def cachingAction[S <: Scope]: ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] = + new ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] { + override protected def executionContext: ExecutionContext = ec + + override def invokeBlock[A]( + request: ApiRequest[S, A], + block: ApiRequest[S, A] => Future[Result] + ): Future[Result] = { + import scalacache.modes.scalaFuture._ + require(request.method == "GET") + + if (request.user.isDefined) { + block(request) + } else { + actionResultCache + .caching[Future]( + request.path, + request.queryString.toSeq.sortBy(_._1) + )(Some(5.minute))(block(request)) + .flatten + } + } + } + + def ApiAction[S <: Scope](perms: Permission, scope: APIScope[S]): ActionBuilder[ApiRequest[S, *], AnyContent] = + Action.andThen(apiAction(scope)).andThen(permApiAction(perms)) + + def CachingApiAction[S <: Scope](perms: Permission, scope: APIScope[S]): ActionBuilder[ApiRequest[S, *], AnyContent] = + ApiAction(perms, scope).andThen(cachingAction) +} diff --git a/apiV2/app/controllers/apiv2/ApiV2Controller.scala b/apiV2/app/controllers/apiv2/ApiV2Controller.scala deleted file mode 100644 index 5bf4d0dbe..000000000 --- a/apiV2/app/controllers/apiv2/ApiV2Controller.scala +++ /dev/null @@ -1,908 +0,0 @@ -package controllers.apiv2 - -import java.nio.file.Path -import java.time.format.DateTimeParseException -import java.time.{LocalDate, OffsetDateTime} -import java.util.UUID - -import scala.collection.immutable -import scala.concurrent.duration._ -import scala.concurrent.{ExecutionContext, Future} -import scala.jdk.CollectionConverters._ - -import play.api.http.{HttpErrorHandler, Writeable} -import play.api.i18n.Lang -import play.api.inject.ApplicationLifecycle -import play.api.libs.Files -import play.api.mvc._ - -import controllers.apiv2.ApiV2Controller._ -import controllers.sugar.CircePlayController -import controllers.sugar.Requests.ApiRequest -import controllers.{OreBaseController, OreControllerComponents} -import db.impl.query.APIV2Queries -import models.protocols.APIV2 -import models.querymodels.{APIV2ProjectStatsQuery, APIV2QueryVersion, APIV2QueryVersionTag, APIV2VersionStatsQuery} -import ore.data.project.Category -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{ApiKeyTable, OrganizationTable, ProjectTable, UserTable} -import ore.db.{DbRef, Model} -import ore.models.api.ApiSession -import ore.models.project.factory.ProjectFactory -import ore.models.project.io.PluginUpload -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 _root_.util.syntax._ - -import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials} -import cats.data.{NonEmptyList, Validated} -import cats.kernel.Semigroup -import cats.syntax.all._ -import enumeratum._ -import io.circe.derivation.annotations.SnakeCaseJsonCodec -import io.circe.syntax._ -import io.circe.{Codec => CirceCodec, _} -import zio.blocking.Blocking -import zio.interop.catz._ -import zio.{IO, Task, UIO, ZIO} - -class ApiV2Controller( - factory: ProjectFactory, - val errorHandler: HttpErrorHandler, - fakeUser: FakeUser, - lifecycle: ApplicationLifecycle -)( - implicit oreComponents: OreControllerComponents -) extends OreBaseController - with CircePlayController { - - implicit def zioMode[R]: scalacache.Mode[ZIO[R, Throwable, *]] = - scalacache.CatsEffect.modes.async[ZIO[R, Throwable, *]] - - private val resultCache = scalacache.caffeine.CaffeineCache[ZIO[Blocking, Result, Result]] - - lifecycle.addStopHook(() => zioRuntime.unsafeRunToFuture(resultCache.close[Task]())) - - 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[_]): IO[Either[Unit, Result], HttpCredentials] = { - def unAuth[A: Writeable](msg: A) = Unauthorized(msg).withHeaders(WWW_AUTHENTICATE -> "OreApi") - - for { - stringAuth <- ZIO.fromOption(request.headers.get(AUTHORIZATION)).orElseFail(Left(())) - parsedAuth = Authorization.parseFromValueString(stringAuth).leftMap { es => - NonEmptyList - .fromList(es) - .fold(Right(unAuth(ApiError("Could not parse authorization header"))))(es2 => - Right(unAuth(ApiErrors(es2.map(_.summary)))) - ) - } - auth <- ZIO.fromEither(parsedAuth) - creds = auth.credentials - res <- { - if (creds.scheme == "OreApi") - ZIO.succeed(creds) - else - ZIO.fail(Right(unAuth(ApiError("Invalid scheme for authorization. Needs to be OreApi")))) - } - } yield res - } - - 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]]] = { - def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> "OreApi") - - val authRequest = for { - creds <- parseAuthHeader(request) - .mapError(_.leftMap(_ => unAuth("No authorization specified")).merge) - token <- ZIO - .fromOption(creds.params.get("session")) - .orElseFail(unAuth("No session specified")) - info <- service - .runDbCon(APIV2Queries.getApiAuthInfo(token).option) - .get - .orElseFail(unAuth("Invalid session")) - res <- { - if (info.expires.isBefore(OffsetDateTime.now())) { - service.deleteWhere(ApiSession)(_.token === token) *> IO.fail(unAuth("Api session expired")) - } else ZIO.succeed(ApiRequest(info, request)) - } - } yield res - - zioToFuture(authRequest.either) - } - } - - def apiScopeToRealScope(scope: APIScope): IO[Option[Nothing], Scope] = scope match { - case APIScope.GlobalScope => UIO.succeed(GlobalScope) - case APIScope.ProjectScope(pluginId) => - service - .runDBIO( - TableQuery[ProjectTable] - .filter(_.pluginId === pluginId) - .map(_.id) - .result - .headOption - ) - .get - .map(ProjectScope) - case APIScope.OrganizationScope(organizationName) => - val q = for { - u <- TableQuery[UserTable] - if u.name === organizationName - o <- TableQuery[OrganizationTable] if u.id === o.id - } yield o.id - - service - .runDBIO(q.result.headOption) - .get - .map(OrganizationScope) - } - - 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: IO[Unit, Permission] = - apiScopeToRealScope(scope).orElseFail(()).flatMap(request.permissionIn[Scope, IO[Unit, *]](_)) - val res = scopePerms.orElseFail(NotFound).ensure(Forbidden)(_.has(perms)) - - zioToFuture(res.either.map(_.swap.toOption)) - } - } - - def ApiAction(perms: Permission, scope: APIScope): ActionBuilder[ApiRequest, AnyContent] = - Action.andThen(apiAction).andThen(permApiAction(perms, scope)) - - private def expiration(duration: FiniteDuration, userChoice: Option[Long]) = { - val durationSeconds = duration.toSeconds - - userChoice - .fold[Option[Long]](Some(durationSeconds))(d => if (d > durationSeconds) None else Some(d)) - .map(OffsetDateTime.now().plusSeconds) - } - - def authenticateUser(): Action[AnyContent] = Authenticated.asyncF { implicit request => - val sessionExpiration = expiration(config.ore.api.session.expiration, None).get //Safe because user choice is None - val uuidToken = UUID.randomUUID().toString - val sessionToInsert = ApiSession(uuidToken, None, Some(request.user.id), sessionExpiration) - - service.insert(sessionToInsert).map { key => - Ok( - ReturnedApiSession( - key.token, - key.expires, - 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(implicit request: Request[ApiSessionProperties]): ZIO[Any, Result, Result] = { - lazy val sessionExpiration = expiration(config.ore.api.session.expiration, request.body.expiresIn) - lazy val publicSessionExpiration = expiration(config.ore.api.session.publicExpiration, request.body.expiresIn) - - def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> "OreApi") - - val uuidToken = UUID.randomUUID().toString - - val sessionToInsert = parseAuthHeader(request) - .flatMap { creds => - creds.params.get("apikey") match { - case Some(ApiKeyRegex(identifier, token)) => - for { - expiration <- ZIO - .succeed(sessionExpiration) - .get - .orElseFail(Right(BadRequest("The requested expiration can't be used"))) - t <- service - .runDbCon(APIV2Queries.findApiKey(identifier, token).option) - .get - .orElseFail(Right(unAuth("Invalid api key"))) - (keyId, keyOwnerId) = t - } yield SessionType.Key -> ApiSession(uuidToken, Some(keyId), Some(keyOwnerId), expiration) - case _ => - ZIO.fail(Right(unAuth("No apikey parameter found in Authorization"))) - } - } - .catchAll { - case Left(_) => - ZIO - .succeed(publicSessionExpiration) - .get - .orElseFail(BadRequest("The requested expiration can't be used")) - .map(expiration => SessionType.Public -> ApiSession(uuidToken, None, None, expiration)) - case Right(e) => ZIO.fail(e) - } - - sessionToInsert - .flatMap(t => service.insert(t._2).tupleLeft(t._1)) - .map { - case (tpe, key) => - Ok( - ReturnedApiSession( - key.token, - key.expires, - tpe - ) - ) - } - } - - def authenticateDev: ZIO[Any, Result, Result] = { - if (fakeUser.isEnabled) { - config.checkDebug() - - val sessionExpiration = expiration(config.ore.api.session.expiration, None).get //Safe because userChoice is None - val uuidToken = UUID.randomUUID().toString - val sessionToInsert = ApiSession(uuidToken, None, Some(fakeUser.id), sessionExpiration) - - service.insert(sessionToInsert).map { key => - Ok( - ReturnedApiSession( - key.token, - key.expires, - SessionType.Dev - ) - ) - } - } else { - IO.fail(Forbidden) - } - } - - def defaultBody[A](parser: BodyParser[A], default: => A): BodyParser[A] = parse.using { request => - if (request.hasBody) parser - else parse.ignore(default) - } - - def authenticate(): Action[ApiSessionProperties] = - Action.asyncF(defaultBody(parseCirce.decodeJson[ApiSessionProperties], ApiSessionProperties(None, None))) { - implicit request => if (request.body._fake.getOrElse(false)) authenticateDev else authenticateKeyPublic - } - - def deleteSession(): Action[AnyContent] = ApiAction(Permission.None, APIScope.GlobalScope).asyncF { - implicit request => - ZIO - .succeed(request.apiInfo.session) - .get - .orElseFail(BadRequest("This request was not made with a session")) - .flatMap(session => service.deleteWhere(ApiSession)(_.token === session)) - .as(NoContent) - } - - 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") - .ensure(NonEmptyList.one("Name too long"))(_.length < 255) - - (permsVal, nameVal) - .mapN { (perms, name) => - val perm = Permission(perms.map(_.permission): _*) - val isSubKey = request.apiInfo.key.forall(_.isSubKey(perm)) - - if (!isSubKey) { - IO.fail(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.fail(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): IO[Result, Boolean]).ifM(ifTaken, ifFree) - } - } - .leftMap((ApiErrors.apply _).andThen(BadRequest.apply(_)).andThen(IO.fail(_))) - .merge - } - - def deleteKey(name: String): Action[AnyContent] = - ApiAction(Permission.EditApiKeys, APIScope.GlobalScope).asyncF { implicit request => - for { - user <- ZIO - .fromOption(request.user) - .orElseFail(BadRequest(ApiError("Public keys can't be used to delete"))) - rowsAffected <- service.runDbCon(APIV2Queries.deleteApiKey(name, user.id.value).run) - } yield if (rowsAffected == 0) NotFound else NoContent - } - - 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[_] - ): IO[Result, (APIScope, Permission)] = - for { - apiScope <- ZIO.fromEither(createApiScope(pluginId, organizationName)) - scope <- apiScopeToRealScope(apiScope).orElseFail(NotFound) - perms <- request.permissionIn(scope) - } yield (apiScope, perms) - - def cachingF[A, B]( - cacheKey: String - )(parts: Any*)(fa: ZIO[Blocking, Result, Result])(implicit request: ApiRequest[B]): ZIO[Blocking, Result, Result] = - resultCache - .cachingF[ZIO[Blocking, Throwable, *]]( - cacheKey +: parts :+ request.apiInfo.key.map(_.tokenIdentifier) :+ request.apiInfo.user - .map(_.id) :+ request.body: _* - )(Some(1.minute))(fa.memoize) - .orElseFail(InternalServerError) - .flatten - - def showPermissions(pluginId: Option[String], organizationName: Option[String]): Action[AnyContent] = - ApiAction(Permission.None, APIScope.GlobalScope).asyncF { implicit request => - cachingF("showPermissions")(pluginId, organizationName) { - permissionsInCreatedApiScope(pluginId, organizationName).map { - case (scope, perms) => - Ok( - KeyPermissions( - scope.tpe, - perms.toNamedSeq.toList - ) - ) - } - } - } - - def has( - cacheKey: String, - permissions: Seq[NamedPermission], - pluginId: Option[String], - organizationName: Option[String] - )( - check: (Seq[NamedPermission], Permission) => Boolean - ): Action[AnyContent] = - ApiAction(Permission.None, APIScope.GlobalScope).asyncF { implicit request => - cachingF(cacheKey)(permissions, pluginId, organizationName) { - 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("hasAll", 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("hasAny", 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 => - cachingF("listProjects")(q, categories, tags, owner, sort, relevance, limit, offset) { - val realLimit = limitOrDefault(limit, config.ore.projects.initLoad) - val realOffset = offsetOrZero(offset) - - val parsedTags = tags.map { s => - val splitted = s.split(":", 2) - (splitted(0), splitted.lift(1)) - } - - val getProjects = APIV2Queries - .projectQuery( - None, - categories.toList, - parsedTags.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, - parsedTags.toList, - q, - owner, - request.globalPermissions.has(Permission.SeeHidden), - request.user.map(_.id) - ) - .unique - - ( - service.runDbCon(getProjects).flatMap(ZIO.foreachParN(config.performance.nioBlockingFibers)(_)(identity)), - service.runDbCon(countProjects) - ).parMapN { (projects, count) => - Ok( - PaginatedProjectResult( - Pagination(realLimit, realOffset, count), - projects - ) - ) - } - } - } - - def showProject(pluginId: String): Action[AnyContent] = - ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("showProject")(pluginId) { - val dbCon = APIV2Queries - .projectQuery( - Some(pluginId), - Nil, - Nil, - None, - None, - request.globalPermissions.has(Permission.SeeHidden), - request.user.map(_.id), - ProjectSortingStrategy.Default, - orderWithRelevance = false, - 1, - 0 - ) - .option - - service.runDbCon(dbCon).get.flatMap(identity).bimap(_ => NotFound, Ok(_)) - } - } - - def showMembers(pluginId: String, limit: Option[Long], offset: Long): Action[AnyContent] = - ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("showMembers")(pluginId, limit, offset) { - service - .runDbCon( - APIV2Queries - .projectMembers(pluginId, limitOrDefault(limit, 25), offsetOrZero(offset)) - .to[Vector] - ) - .map(xs => Ok(xs.asJson)) - } - } - - def showProjectStats(pluginId: String, fromDateString: String, toDateString: String): Action[AnyContent] = - ApiAction(Permission.IsProjectMember, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("projectStats")(pluginId, fromDateString, toDateString) { - import Ordering.Implicits._ - - def parseDate(dateStr: String) = - Validated - .catchOnly[DateTimeParseException](LocalDate.parse(dateStr)) - .leftMap(_ => ApiErrors(NonEmptyList.one(s"Badly formatted date $dateStr"))) - - for { - t <- ZIO - .fromEither(parseDate(fromDateString).product(parseDate(toDateString)).toEither) - .mapError(BadRequest(_)) - (fromDate, toDate) = t - _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) - res <- service.runDbCon( - APIV2Queries.projectStats(pluginId, fromDate, toDate).to[Vector].map(APIV2ProjectStatsQuery.asProtocol) - ) - } yield Ok(res.asJson) - } - } - - def listVersions( - pluginId: String, - tags: Seq[String], - limit: Option[Long], - offset: Long - ): Action[AnyContent] = - ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("listVersions")(pluginId, tags, limit, offset) { - val realLimit = limitOrDefault(limit, config.ore.projects.initVersionLoad.toLong) - val realOffset = offsetOrZero(offset) - val getVersions = APIV2Queries - .versionQuery( - pluginId, - None, - tags.toList, - request.globalPermissions.has(Permission.SeeHidden), - request.user.map(_.id), - realLimit, - realOffset - ) - .to[Vector] - - val countVersions = APIV2Queries - .versionCountQuery( - pluginId, - tags.toList, - request.globalPermissions.has(Permission.SeeHidden), - request.user.map(_.id) - ) - .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] = - ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("showVersion")(pluginId, name) { - service - .runDbCon( - APIV2Queries - .versionQuery( - pluginId, - Some(name), - Nil, - request.globalPermissions.has(Permission.SeeHidden), - request.user.map(_.id), - 1, - 0 - ) - .option - ) - .map(_.fold(NotFound: Result)(a => Ok(a.asJson))) - } - } - - def showVersionStats( - pluginId: String, - version: String, - fromDateString: String, - toDateString: String - ): Action[AnyContent] = - ApiAction(Permission.IsProjectMember, APIScope.ProjectScope(pluginId)).asyncF { implicit request => - cachingF("versionStats")(pluginId, version, fromDateString, toDateString) { - import Ordering.Implicits._ - - def parseDate(dateStr: String) = - Validated - .catchOnly[DateTimeParseException](LocalDate.parse(dateStr)) - .leftMap(_ => ApiErrors(NonEmptyList.one(s"Badly formatted date $dateStr"))) - - for { - t <- ZIO - .fromEither(parseDate(fromDateString).product(parseDate(toDateString)).toEither) - .mapError(BadRequest(_)) - (fromDate, toDate) = t - _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) - res <- service.runDbCon( - APIV2Queries - .versionStats(pluginId, version, fromDate, toDate) - .to[Vector] - .map(APIV2VersionStatsQuery.asProtocol) - ) - } yield Ok(res.asJson) - } - } - - //TODO: Do the async part at some point - private def readFileAsync(file: Path): ZIO[Blocking, Throwable, String] = { - import zio.blocking._ - effectBlocking(java.nio.file.Files.readAllLines(file).asScala.mkString("\n")) - } - - def deployVersion(pluginId: String): Action[MultipartFormData[Files.TemporaryFile]] = - ApiAction(Permission.CreateVersion, APIScope.ProjectScope(pluginId))(parse.multipartFormData).asyncF { - implicit request => - type TempFile = MultipartFormData.FilePart[Files.TemporaryFile] - import zio.blocking._ - - val pluginInfoFromFileF = ZIO.bracket( - acquire = UIO(request.body.file("plugin-info")).get.mapError(Left.apply), - release = (filePart: TempFile) => effectBlocking(java.nio.file.Files.deleteIfExists(filePart.ref)).fork, - use = (filePart: TempFile) => readFileAsync(filePart.ref).mapError(Right.apply) - ) - - val dataStringF = ZIO - .fromOption(request.body.dataParts.get("plugin-info").flatMap(_.headOption)) - .orElse(pluginInfoFromFileF) - .catchAll { - case Left(_) => IO.fail("No plugin info specified") - case Right(e) => IO.die(e) - } - - val dataF = dataStringF - .flatMap(s => ZIO.fromEither(parser.decode[DeployVersionInfo](s).leftMap(_.show))) - .ensure("Description too long")(_.description.forall(_.length < Page.maxLength)) - .mapError(e => BadRequest(ApiError(e))) - - val fileF = ZIO.fromEither( - request.body.file("plugin-file").toRight(BadRequest(ApiError("No plugin file specified"))) - ) - - def uploadErrors(user: Model[User]) = { - implicit val lang: Lang = user.langOrDefault - ZIO.fromEither( - factory - .getUploadError(user) - .map(e => BadRequest(UserError(messagesApi(e)))) - .toLeft(()) - ) - } - - for { - user <- ZIO.fromOption(request.user).orElseFail(BadRequest(ApiError("No user found for session"))) - _ <- uploadErrors(user) - project <- projects.withPluginId(pluginId).get.orElseFail(NotFound) - 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.createForumPost.getOrElse(project.settings.forumSync), - channelName = - data.tags.getOrElse(Map.empty).view.mapValues(_.first).getOrElse("Channel", v.channelName), - description = data.description - ) - } - t <- pendingVersion.complete(project, factory) - } yield { - val (_, version, channel, tags) = t - - val normalApiTags = tags.map(tag => APIV2QueryVersionTag(tag.name, tag.data, tag.color)).toList - val channelApiTag = APIV2QueryVersionTag( - "Channel", - Some(channel.name), - channel.color.toTagColor - ) - val apiTags = channelApiTag :: normalApiTags - val apiVersion = APIV2QueryVersion( - version.createdAt, - version.versionString, - version.dependencyIds, - version.visibility, - version.description, - 0, - version.fileSize, - version.hash, - version.fileName, - Some(user.name), - version.reviewState, - apiTags - ) - - Created(apiVersion.asProtocol) - } - } - - def showUser(user: String): Action[AnyContent] = - ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { implicit request => - cachingF("showUser")(user) { - service.runDbCon(APIV2Queries.userQuery(user).option).map(_.fold(NotFound: Result)(a => Ok(a.asJson))) - } - } - - def showStarred( - user: String, - sort: Option[ProjectSortingStrategy], - limit: Option[Long], - offset: Long - ): Action[AnyContent] = - showUserAction("showStarred")( - user, - sort, - limit, - offset, - APIV2Queries.starredQuery, - APIV2Queries.starredCountQuery - ) - - def showWatching( - user: String, - sort: Option[ProjectSortingStrategy], - limit: Option[Long], - offset: Long - ): Action[AnyContent] = - showUserAction("showWatching")( - user, - sort, - limit, - offset, - APIV2Queries.watchingQuery, - APIV2Queries.watchingCountQuery - ) - - def showUserAction(cacheKey: String)( - user: String, - sort: Option[ProjectSortingStrategy], - limit: Option[Long], - offset: Long, - query: ( - String, - Boolean, - Option[DbRef[User]], - ProjectSortingStrategy, - Long, - Long - ) => doobie.Query0[Either[DecodingFailure, APIV2.CompactProject]], - countQuery: (String, Boolean, Option[DbRef[User]]) => doobie.Query0[Long] - ): Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { implicit request => - cachingF(cacheKey)(user, sort, limit, offset) { - 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).flatMap(ZIO.foreach(_)(ZIO.fromEither(_))).orDie, service.runDbCon(countProjects)) - .parMapN { (projects, count) => - Ok( - PaginatedCompactProjectResult( - Pagination(realLimit, offset, count), - projects - ) - ) - } - } - } -} -object ApiV2Controller { - - 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 codec: CirceCodec[APIScopeType] = APIV2.enumCodec(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 codec: CirceCodec[SessionType] = APIV2.enumCodec(SessionType)(_.entryName) - } - - @SnakeCaseJsonCodec case class ApiError(error: String) - @SnakeCaseJsonCodec case class ApiErrors(errors: NonEmptyList[String]) - object ApiErrors { - implicit val semigroup: Semigroup[ApiErrors] = (x: ApiErrors, y: ApiErrors) => - ApiErrors(x.errors.concatNel(y.errors)) - } - @SnakeCaseJsonCodec case class UserError(userError: String) - - @SnakeCaseJsonCodec case class KeyToCreate(name: String, permissions: Seq[String]) - @SnakeCaseJsonCodec case class CreatedApiKey(key: String, perms: Seq[NamedPermission]) - - @SnakeCaseJsonCodec case class DeployVersionInfo( - createForumPost: Option[Boolean], - description: Option[String], - tags: Option[Map[String, StringOrArrayString]] - ) - - sealed trait StringOrArrayString { - def first: String - } - object StringOrArrayString { - case class AsString(s: String) extends StringOrArrayString { - def first: String = s - } - case class AsArray(ss: Seq[String]) extends StringOrArrayString { - override def first: String = ss.head - } - - implicit val codec: CirceCodec[StringOrArrayString] = CirceCodec.from( - (c: HCursor) => c.as[String].map(AsString).orElse(c.as[Seq[String]].map(AsArray)), { - case AsString(s) => s.asJson - case AsArray(ss) => ss.asJson - } - ) - } - - @SnakeCaseJsonCodec case class ApiSessionProperties( - _fake: Option[Boolean], - expiresIn: Option[Long] - ) - - @SnakeCaseJsonCodec case class ReturnedApiSession( - session: String, - expires: OffsetDateTime, - `type`: SessionType - ) - - @SnakeCaseJsonCodec case class PaginatedProjectResult( - pagination: Pagination, - result: Seq[APIV2.Project] - ) - - @SnakeCaseJsonCodec case class PaginatedCompactProjectResult( - pagination: Pagination, - result: Seq[APIV2.CompactProject] - ) - - @SnakeCaseJsonCodec case class PaginatedVersionResult( - pagination: Pagination, - result: Seq[APIV2.Version] - ) - - @SnakeCaseJsonCodec case class Pagination( - limit: Long, - offset: Long, - count: Long - ) - - implicit val namedPermissionCodec: CirceCodec[NamedPermission] = APIV2.enumCodec(NamedPermission)(_.entryName) - - @SnakeCaseJsonCodec case class KeyPermissions( - `type`: APIScopeType, - permissions: List[NamedPermission] - ) - - @SnakeCaseJsonCodec case class PermissionCheck( - `type`: APIScopeType, - result: Boolean - ) -} diff --git a/apiV2/app/controllers/apiv2/Authentication.scala b/apiV2/app/controllers/apiv2/Authentication.scala new file mode 100644 index 000000000..b371c86d8 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Authentication.scala @@ -0,0 +1,182 @@ +package controllers.apiv2 + +import java.time.OffsetDateTime +import java.util.UUID + +import scala.collection.immutable +import scala.concurrent.duration.FiniteDuration + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Codec => _, _} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers.{APIScope, ApiError} +import db.impl.query.apiv2.AuthQueries +import models.protocols.APIV2 +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.api.ApiSession +import ore.models.user.FakeUser +import ore.permission.Permission + +import akka.http.scaladsl.model.headers.HttpCredentials +import enumeratum.{Enum, EnumEntry} +import io.circe._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import zio.{IO, ZIO} + +class Authentication( + val errorHandler: HttpErrorHandler, + fakeUser: FakeUser, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + import Authentication._ + + private def expiration(duration: FiniteDuration, userChoice: Option[Long]) = { + val durationSeconds = duration.toSeconds + + userChoice + .fold[Option[Long]](Some(durationSeconds))(d => if (d > durationSeconds) None else Some(d)) + .map(OffsetDateTime.now().plusSeconds) + } + + def authenticateUser(): Action[AnyContent] = Authenticated.asyncF { implicit request => + val sessionExpiration = expiration(config.ore.api.session.expiration, None).get //Safe because user choice is None + val uuidToken = UUID.randomUUID().toString + val sessionToInsert = ApiSession(uuidToken, None, Some(request.user.id), sessionExpiration) + + service.insert(sessionToInsert).map { key => + Ok( + ReturnedApiSession( + key.token, + key.expires, + 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(implicit request: Request[ApiSessionProperties]): ZIO[Any, Result, Result] = { + lazy val sessionExpiration = expiration(config.ore.api.session.expiration, request.body.expiresIn) + lazy val publicSessionExpiration = expiration(config.ore.api.session.publicExpiration, request.body.expiresIn) + + def expirationToZIO(expiration: Option[OffsetDateTime]) = + ZIO + .fromOption(expiration) + .orElseFail(BadRequest(ApiError("The requested expiration can't be used"))) + + def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> "OreApi") + + val uuidToken = UUID.randomUUID().toString + + val findApiKey = ZIO.accessM[HttpCredentials] { creds => + creds.params.get("apikey") match { + case Some(ApiKeyRegex(identifier, token)) => ZIO.succeed(AuthQueries.findApiKey(identifier, token).option) + case _ => ZIO.fail(unAuth("No or invalid apikey parameter found in Authorization")) + } + } + + val validateCreds = for { + expiration <- expirationToZIO(sessionExpiration) + findQuery <- findApiKey + t <- service.runDbCon(findQuery).get.orElseFail(unAuth("Invalid api key")) + (keyId, keyOwnerId) = t + } yield SessionType.Key -> ApiSession(uuidToken, Some(keyId), Some(keyOwnerId), expiration) + + val parsed = parseAuthHeader(request) + .map(Right.apply) + .catchAll { + case ParseAuthHeaderError.NoAuthHeader => + expirationToZIO(publicSessionExpiration).map { expiration => + Left(SessionType.Public -> ApiSession(uuidToken, None, None, expiration)) + } + case e => ZIO.fail(e.toResult) + } + + //Only validate the credentials if they are present + val sessionToInsert = parsed >>> (ZIO.identity[(Authentication.SessionType, ApiSession)] ||| validateCreds) + + for { + t <- sessionToInsert + (sessionType, session) = t + _ <- service.insert(session) + } yield Ok( + ReturnedApiSession( + session.token, + session.expires, + sessionType + ) + ) + } + + def authenticateDev: ZIO[Any, Result, Result] = { + if (fakeUser.isEnabled) { + config.checkDebug() + + val sessionExpiration = expiration(config.ore.api.session.expiration, None).get //Safe because userChoice is None + val uuidToken = UUID.randomUUID().toString + val sessionToInsert = ApiSession(uuidToken, None, Some(fakeUser.id), sessionExpiration) + + service.insert(sessionToInsert).map { key => + Ok( + ReturnedApiSession( + key.token, + key.expires, + SessionType.Dev + ) + ) + } + } else { + IO.fail(Forbidden) + } + } + + def defaultBody[A](parser: BodyParser[A], default: => A): BodyParser[A] = parse.using { request => + if (request.hasBody) parser + else parse.ignore(default) + } + + def authenticate(): Action[ApiSessionProperties] = + Action.asyncF(defaultBody(parseCirce.decodeJson[ApiSessionProperties], ApiSessionProperties(None, None))) { + implicit request => if (request.body._fake.getOrElse(false)) authenticateDev else authenticateKeyPublic + } + + def deleteSession(): Action[AnyContent] = ApiAction(Permission.None, APIScope.GlobalScope).asyncF { request => + ZIO + .fromOption(request.apiInfo.session) + .orElseFail(BadRequest(ApiError("This request was not made with a session"))) + .flatMap(session => service.deleteWhere(ApiSession)(_.token === session)) + .as(NoContent) + } +} +object Authentication { + + 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 codec: Codec[SessionType] = APIV2.enumCodec(SessionType)(_.entryName) + } + + @SnakeCaseJsonCodec case class ApiSessionProperties( + _fake: Option[Boolean], + expiresIn: Option[Long] + ) + + @SnakeCaseJsonCodec case class ReturnedApiSession( + session: String, + expires: OffsetDateTime, + `type`: SessionType + ) +} diff --git a/apiV2/app/controllers/apiv2/Keys.scala b/apiV2/app/controllers/apiv2/Keys.scala new file mode 100644 index 000000000..b64d1ae84 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Keys.scala @@ -0,0 +1,84 @@ +package controllers.apiv2 + +import java.util.UUID + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent, Result} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} +import db.impl.query.apiv2.AuthQueries +import models.protocols.APIV2 +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.schema.ApiKeyTable +import ore.permission.{NamedPermission, Permission} + +import cats.data.NonEmptyList +import cats.syntax.all._ +import io.circe.Codec +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import zio.interop.catz._ +import zio.{IO, ZIO} + +class Keys( + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + import Keys._ + + 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") + .ensure(NonEmptyList.one("Name too long"))(_.length < 255) + + (permsVal, nameVal) + .mapN { (perms, name) => + val perm = Permission(perms.map(_.permission): _*) + val isSubKey = request.apiInfo.key.forall(_.isSubKey(perm)) + + if (!isSubKey) { + IO.fail(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.fail(Conflict(ApiError("Name already taken"))) + val ifFree = service + .runDbCon(AuthQueries.createApiKey(name, ownerId, tokenIdentifier, token, perm).run) + .map(_ => Ok(CreatedApiKey(s"$tokenIdentifier.$token", perm.toNamedSeq))) + + (service.runDBIO(nameTaken): IO[Result, Boolean]).ifM(ifTaken, ifFree) + } + } + .leftMap((ApiErrors.apply _).andThen(BadRequest.apply(_)).andThen(IO.fail(_))) + .merge + } + + def deleteKey(name: String): Action[AnyContent] = + ApiAction(Permission.EditApiKeys, APIScope.GlobalScope).asyncF { implicit request => + for { + user <- ZIO + .fromOption(request.user) + .orElseFail(BadRequest(ApiError("Public keys can't be used to delete"))) + rowsAffected <- service.runDbCon(AuthQueries.deleteApiKey(name, user.id.value).run) + } yield if (rowsAffected == 0) NotFound else NoContent + } +} +object Keys { + + implicit val namedPermissionCodec: Codec[NamedPermission] = APIV2.enumCodec(NamedPermission)(_.entryName) + + @SnakeCaseJsonCodec case class KeyToCreate(name: String, permissions: Seq[String]) + @SnakeCaseJsonCodec case class CreatedApiKey(key: String, perms: Seq[NamedPermission]) +} diff --git a/apiV2/app/controllers/apiv2/Organizations.scala b/apiV2/app/controllers/apiv2/Organizations.scala new file mode 100644 index 000000000..f8f21b062 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Organizations.scala @@ -0,0 +1,52 @@ +package controllers.apiv2 + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent, Result} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers.{APIScope, Members} +import db.impl.query.apiv2.{OrganizationQueries, UserQueries} +import ore.data.user.notification.NotificationType +import ore.db.impl.schema.OrganizationRoleTable +import ore.models.organization.Organization +import ore.models.user.role.OrganizationUserRole +import ore.permission.Permission + +import io.circe.syntax._ +import zio.interop.catz._ +import zio._ + +class Organizations( + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + + def showOrganization(organization: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { + service + .runDbCon(OrganizationQueries.organizationQuery(organization).option) + .map(_.fold(NotFound: Result)(a => Ok(a.asJson))) + } + + def showOrgMembers(organization: String, limit: Option[Long], offset: Long): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.OrganizationScope(organization)).asyncF { implicit r => + Members.membersAction(UserQueries.orgaMembers(r.scope.id, _, _), limit, offset) + } + + def updateOrgMembers(organization: String): Action[List[Members.MemberUpdate]] = + ApiAction(Permission.ManageOrganizationMembers, APIScope.OrganizationScope(organization)) + .asyncF(parseCirce.decodeJson[List[Members.MemberUpdate]]) { implicit r => + Members.updateMembers[Organization, OrganizationUserRole, OrganizationRoleTable]( + getSubject = r.organization, + allowOrgMembers = false, + getMembersQuery = UserQueries.orgaMembers(r.scope.id, _, _), + createRole = OrganizationUserRole(_, _, _), + roleCompanion = OrganizationUserRole, + notificationType = NotificationType.OrganizationInvite, + notificationLocalization = "notification.organization.invite" + ) + } +} diff --git a/apiV2/app/controllers/apiv2/Pages.scala b/apiV2/app/controllers/apiv2/Pages.scala new file mode 100644 index 000000000..96fda7a8d --- /dev/null +++ b/apiV2/app/controllers/apiv2/Pages.scala @@ -0,0 +1,177 @@ +package controllers.apiv2 + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} +import controllers.sugar.Requests.ApiRequest +import db.impl.query.apiv2.PageQueries +import models.protocols.APIV2 +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.schema.PageTable +import ore.models.project.{Page, Project} +import ore.permission.Permission +import ore.permission.scope.ProjectScope +import ore.util.StringUtils +import util.PatchDecoder +import util.syntax._ + +import cats.data.Validated +import cats.instances.option._ +import cats.syntax.all._ +import io.circe.{Decoder, Json} +import slick.lifted.TableQuery +import perspective._ +import perspective.syntax.all._ +import perspective.macros.Derive +import zio.ZIO +import zio.interop.catz._ + +class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + + def showPages(projectOwner: String, projectSlug: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service.runDbCon(PageQueries.pageList(request.scope.id).to[Vector]).flatMap { pages => + if (pages.isEmpty) ZIO.fail(NotFound) + else ZIO.succeed(Ok(APIV2.PageList(pages.map(t => APIV2.PageListEntry(t._2, t._3, t._4))))) + } + } + + private def getPageOpt( + page: String + )(implicit request: ApiRequest[ProjectScope, _]): ZIO[Any, Option[Nothing], (DbRef[Page], String, Option[String])] = + service + .runDbCon(PageQueries.getPage(request.scope.id, page).option) + .get + + private def getPage( + page: String + )(implicit request: ApiRequest[ProjectScope, _]): ZIO[Any, Status, (DbRef[Page], String, Option[String])] = + getPageOpt(page).orElseFail(NotFound) + + def showPageAction(projectOwner: String, projectSlug: String, page: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => + getPage(page).map { + case (_, name, contents) => + Ok(APIV2.Page(name, contents)) + } + } + + def putPage(projectOwner: String, projectSlug: String, page: String): Action[APIV2.Page] = + ApiAction(Permission.EditPage, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[APIV2.Page]) { implicit r => + val newName = StringUtils.compact(r.body.name) + val content = r.body.content + + val pageArr = page.split("/") + val pageParent = pageArr.init.mkString("/") + val slug = StringUtils.slugify(pageArr.last) //TODO: Check ASCII + + val updateExisting = getPageOpt(page).flatMap { + case (id, _, _) => + service + .runDBIO( + TableQuery[PageTable].filter(_.id === id).map(p => (p.name, p.contents)).update((newName, content)) + ) + .as(Ok(APIV2.Page(newName, content))) + } + + def insertNewPage(parentId: Option[DbRef[Page]]) = + service + .insert(Page(r.scope.id, parentId, newName, slug, isDeletable = true, content)) + .as(Created(APIV2.Page(newName, content))) + + val createNew = + if (page.contains("/")) { + getPage(pageParent).flatMap { + case (parentId, _, _) => + insertNewPage(Some(parentId)) + } + } else { + insertNewPage(None) + } + + if (page == Page.homeName && content.fold(0)(_.length) < Page.minLength) + ZIO.fail(BadRequest(ApiError("Too short content"))) + else if (content.fold(0)(_.length) > Page.maxLengthPage) ZIO.fail(BadRequest(ApiError("Too long content"))) + else updateExisting.orElse(createNew) + } + + def patchPage(projectOwner: String, projectSlug: String, page: String): Action[Json] = + ApiAction(Permission.EditPage, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.json) { implicit request => + val root = request.body.hcursor + + val res: Decoder.AccumulatingResult[Pages.PatchPageF[Option]] = Pages.PatchPageF.patchDecoder.traverseKC( + λ[PatchDecoder ~>: Compose2[Decoder.AccumulatingResult, Option, *]](_.decode(root)) + ) + + res match { + case Validated.Valid(a) => + val newName = a.copy[Option]( + name = a.name.map(StringUtils.compact) + ) + + val slug = newName.name.map(StringUtils.slugify) + + val oldPage = getPage(page) + val newParent = + newName.parent + .map(_.map(p => getPageOpt(p).map(_._1)).sequence) + .sequence + .orElseFail(BadRequest(ApiError("Unknown parent"))) + + val runRename = (oldPage <&> newParent).flatMap { + case ((id, name, contents), parentId) => + service + .runDbCon(PageQueries.patchPage(newName, slug, id, parentId).run) + .as(Ok(APIV2.Page(newName.name.getOrElse(name), newName.content.getOrElse(contents)))) + } + + if (newName.content.flatten.fold(0)(_.length) > Page.maxLengthPage) + ZIO.fail(BadRequest(ApiError("Too long content"))) + else runRename + case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e.map(_.show)))) + } + } + + def deletePage(projectOwner: String, projectSlug: String, page: String): Action[AnyContent] = + ApiAction(Permission.EditPage, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => + getPage(page) + .flatMap { + case (id, _, _) => + //TODO: In the future when we represent the tree in a better way, just promote all children one level up + service.deleteWhere(Page)(p => + p.id === id && p.isDeletable && TableQuery[PageTable].filter(_.parentId === id).size === 0 + ) + } + .map { + case 0 => BadRequest(ApiError("Page not deletable")) + case _ => NoContent + } + } +} +object Pages { + case class PatchPageF[F[_]]( + name: F[String], + content: F[Option[String]], + parent: F[Option[String]] + ) + object PatchPageF { + implicit val F: TraverseKC[PatchPageF] with RepresentableKC[PatchPageF] { type RepresentationK[A] = Finite[3] } = + Derive.allKC[PatchPageF, λ[A => Finite[3]]] + + val patchDecoder: PatchPageF[PatchDecoder] = { + val namesWithImplicits: PatchPageF[Tuple2K[Derive.Names, Decoder, *]] = + Derive.namesWithImplicitsC[PatchPageF, Decoder] + PatchDecoder.fromName(namesWithImplicits)( + io.circe.derivation.renaming.snakeCase + ) + } + } +} diff --git a/apiV2/app/controllers/apiv2/Permissions.scala b/apiV2/app/controllers/apiv2/Permissions.scala new file mode 100644 index 000000000..448ea05c8 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Permissions.scala @@ -0,0 +1,94 @@ +package controllers.apiv2 + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers.{APIScope, APIScopeType} +import ore.permission.{NamedPermission, Permission} + +import io.circe.derivation.annotations.SnakeCaseJsonCodec + +class Permissions( + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + import Permissions._ + + def showPermissions( + projectOwner: Option[String], + projectSlug: Option[String], + organizationName: Option[String] + ): Action[AnyContent] = { + createApiScope(projectOwner, projectSlug, organizationName) match { + case Right(scope) => + CachingApiAction(Permission.None, scope) { implicit request => + Ok( + KeyPermissions( + scope.tpe, + request.scopePermission.toNamedSeq.toList + ) + ) + } + case Left(error) => + //We still run auth and such + CachingApiAction(Permission.None, APIScope.GlobalScope) { + error + } + } + } + + def has( + checkPermissions: Seq[NamedPermission], + projectOwner: Option[String], + projectSlug: Option[String], + organizationName: Option[String] + )( + check: (Seq[Permission], Permission) => Boolean + ): Action[AnyContent] = { + createApiScope(projectOwner, projectSlug, organizationName) match { + case Right(scope) => + CachingApiAction(Permission.None, scope) { implicit request => + Ok(PermissionCheck(scope.tpe, check(checkPermissions.map(_.permission), request.scopePermission))) + } + case Left(error) => + //We still run auth and such + CachingApiAction(Permission.None, APIScope.GlobalScope) { + error + } + } + } + + def hasAll( + permissions: Seq[NamedPermission], + projectOwner: Option[String], + projectSlug: Option[String], + organizationName: Option[String] + ): Action[AnyContent] = + //equivalent to seq.forall(perm.has(_)), but should be faster + has(permissions, projectOwner, projectSlug, organizationName)((seq, perm) => Permission(seq: _*).has(perm)) + + def hasAny( + permissions: Seq[NamedPermission], + projectOwner: Option[String], + projectSlug: Option[String], + organizationName: Option[String] + ): Action[AnyContent] = + has(permissions, projectOwner, projectSlug, organizationName)((seq, perm) => seq.exists(perm.has(_))) +} +object Permissions { + import models.protocols.APIV2.namedPermissionCodec + + @SnakeCaseJsonCodec case class KeyPermissions( + `type`: APIScopeType, + permissions: List[NamedPermission] + ) + + @SnakeCaseJsonCodec case class PermissionCheck( + `type`: APIScopeType, + result: Boolean + ) +} diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala new file mode 100644 index 000000000..6432627a3 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -0,0 +1,561 @@ +package controllers.apiv2 + +import java.net.URLEncoder +import java.time.LocalDate +import java.time.format.DateTimeParseException + +import play.api.http.HttpErrorHandler +import play.api.i18n.Lang +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent, RequestHeader, Result} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers._ +import controllers.sugar.Requests.ApiRequest +import db.impl.query.apiv2.{ActionsAndStatsQueries, OrganizationQueries, PageQueries, ProjectQueries, UserQueries} +import models.protocols.APIV2 +import models.querymodels.APIV2ProjectStatsQuery +import models.viewhelper.ProjectData +import ore.OreConfig +import ore.data.project.Category +import ore.data.user.notification.NotificationType +import ore.db.Model +import ore.db.impl.schema.ProjectRoleTable +import ore.models.Job +import ore.models.project.factory.{ProjectFactory, ProjectTemplate} +import ore.models.project.{Project, ProjectSortingStrategy, Version} +import ore.models.user.role.ProjectUserRole +import ore.models.user.{LoggedActionProject, LoggedActionType} +import ore.permission.Permission +import ore.util.{OreMDC, StringUtils} +import util.syntax._ +import util.{PartialUtils, PatchDecoder, UserActionLogger} + +import cats.data.{NonEmptyList, Validated, ValidatedNel} +import cats.syntax.all._ +import io.circe._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import io.circe.syntax._ +import perspective._ +import perspective.macros.Derive +import zio.blocking.Blocking +import zio.interop.catz._ +import zio.{Task, UIO, ZIO} + +class Projects( + factory: ProjectFactory, + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + import Projects._ + + def listProjects( + q: Option[String], + categories: Seq[Category], + platforms: Seq[String], + stability: Seq[Version.Stability], + owner: Option[String], + sort: Option[ProjectSortingStrategy], + relevance: Option[Boolean], + exact: Option[Boolean], + limit: Option[Long], + offset: Long + ): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { implicit request => + val realLimit = limitOrDefault(limit, config.ore.projects.initLoad) + val realOffset = offsetOrZero(offset) + + val parsedPlatforms = platforms.map { s => + val splitted = s.split(":", 2) + (splitted(0), splitted.lift(1)) + } + + val getProjects = ProjectQueries + .projectQuery( + None, + categories.toList, + parsedPlatforms.toList, + stability.toList, + q, + owner, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id), + sort.getOrElse(ProjectSortingStrategy.Default), + relevance.getOrElse(true), + exact.getOrElse(false), + realLimit, + realOffset + ) + .to[Vector] + + val countProjects = ProjectQueries + .projectCountQuery( + None, + categories.toList, + parsedPlatforms.toList, + stability.toList, + q, + owner, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id), + exact.getOrElse(false) + ) + .unique + + ( + service.runDbCon(getProjects).flatMap(ZIO.foreachParN(config.performance.nioBlockingFibers)(_)(identity)), + service.runDbCon(countProjects) + ).parMapN { (projects, count) => + Ok( + PaginatedProjectResult( + Pagination(realLimit, realOffset, count), + projects + ) + ) + } + } + + //We check the perms ourselves later for this one + def createProject(): Action[ApiV2ProjectTemplate] = + ApiAction(Permission.None, APIScope.GlobalScope).asyncF(parseCirce.decodeJson[ApiV2ProjectTemplate]) { + implicit request => + val user = request.user.get + val settings = request.body + implicit val lang: Lang = user.langOrDefault + + for { + canUpload <- { + if (settings.ownerName == user.name) ZIO.succeed((user.id.value, true)) + else + service + .runDbCon(OrganizationQueries.canUploadToOrg(user.id, settings.ownerName).option) + .get + .orElseFail(BadRequest(ApiError("Owner not found"))) + } + _ <- ZIO.unit.filterOrFail(_ => canUpload._2)(Forbidden(ApiError("Can't upload to that org"))) + project <- factory + .createProject(canUpload._1, settings.ownerName, settings.asFactoryTemplate) + .mapError(e => BadRequest(UserError(messagesApi(e)))) + } yield { + + Created( + APIV2.Project( + project.createdAt, + project.pluginId, + project.name, + APIV2.ProjectNamespace(project.ownerName, project.slug), + Nil, + APIV2.ProjectStatsAll( + views = 0, + downloads = 0, + recentViews = 0, + recentDownloads = 0, + stars = 0, + watchers = 0 + ), + project.category, + project.description, + project.createdAt, + project.visibility, + APIV2.UserActions(starred = false, watching = false), + APIV2.ProjectSettings( + project.settings.keywords, + project.settings.homepage, + project.settings.issues, + project.settings.source, + project.settings.support, + APIV2.ProjectLicense( + project.settings.licenseName, + project.settings.licenseUrl + ), + project.settings.forumSync + ), + _root_.controllers.project.routes.Projects.showIcon(project.ownerName, project.slug).absoluteURL(), + APIV2.ProjectExternal( + APIV2.ProjectExternalDiscourse( + None, + None + ) + ) + ) + ).withHeaders("Location" -> routes.Projects.showProject(project.ownerName, project.slug).absoluteURL()) + } + } + + //We need to let this one pass through to the redirect if it fails. Need a bit extra code to do that + def showProject(projectOwner: String, projectSlug: String): Action[AnyContent] = + CachingApiAction(Permission.None, APIScope.GlobalScope).asyncF { implicit request => + APIScope + .ProjectScope(projectOwner, projectSlug) + .toRealScope + .mapError[Either[Unit, Unit]](_ => Right(())) + .flatMap(request.permissionIn(_)) + .filterOrFail(_.has(Permission.ViewPublicInfo))(Left(())) + .flatMap { _ => + val dbCon = ProjectQueries + .singleProjectQuery( + projectOwner, + projectSlug, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id) + ) + .option + + service.runDbCon(dbCon).get.flatten.orElseFail(Left(())).map(Ok(_)) + } + .flatMapError { + case Left(_) => ZIO.succeed(NotFound) + case Right(_) => tryRedirectToNewUrls(projectOwner, projectSlug).merge + } + } + + def showProjectDescription(projectOwner: String, projectSlug: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service.runDbCon(PageQueries.getPage(request.scope.id, "Home").option).get.orElseFail(NotFound).map { + case (_, _, contents) => + Ok(Json.obj("description" := contents)) + } + } + + def editProject(projectOwner: String, projectSlug: String): Action[Json] = + ApiAction(Permission.EditProjectSettings, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.json) { implicit request => + val res: ValidatedNel[String, EditableProject] = PartialUtils.decodeAndValidate( + EditableProjectF.patchDecoder, + EditableProjectF.validation, + request.body.hcursor + ) + + res match { + case Validated.Valid(edits) => + //Renaming or transferring a project is a big deal, and can't be done as easily as most other things + val withoutNameAndOwner = edits.copy[Option]( + name = None, + namespace = EditableProjectNamespaceF[Option](None) + ) + + def checkIsOwner(action: String): ZIO[Any, Result, Unit] = + if (request.scopePermission.has(Permission.IsProjectOwner)) ZIO.unit + else ZIO.fail(Forbidden(ApiError(s"Not enough perms to $action"))) + + val renameOp = edits.name.fold(ZIO.unit: ZIO[Any, Result, Unit]) { newName => + val doRename = + request.project.flatMap(projects.rename(_, newName).absolve.mapError(e => BadRequest(ApiError(e)))) + + checkIsOwner("rename project") *> doRename + } + + val transferOp = edits.namespace.owner.fold(ZIO.unit: ZIO[Any, Result, Unit]) { newOwner => + val doTransfer = for { + project <- request.project + user <- users.withName(newOwner)(OreMDC.NoMDC).value.someOrFail(NotFound) + userRole <- project + .memberships[Task, ProjectUserRole, ProjectRoleTable] + .getMembership(project)(user.id) + .orDie + .someOrFail(BadRequest(ApiError("User to transfer to is not member"))) + _ <- if (userRole.isAccepted) ZIO.unit + else ZIO.fail(BadRequest(ApiError("User to transfer to has not accepted invite"))) + _ <- if (userRole.role == ore.permission.role.Role.ProjectAdmin) ZIO.unit + else ZIO.fail(BadRequest(ApiError("User to transfer to is not project admin"))) + _ <- projects.transfer(project, user.id) + } yield () + + checkIsOwner("transfer project") *> doTransfer + } + + val newOwner = edits.namespace.owner.getOrElse(projectOwner) + val newSlug = edits.name.fold(projectSlug)(StringUtils.slugify) + + val update = service.runDbCon(ProjectQueries.updateProject(request.scope.id, withoutNameAndOwner).run) + + //We need two queries two queries as we use the generic update function + val get = service + .runDbCon( + ProjectQueries + .singleProjectQuery( + newOwner, + newSlug, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id) + ) + .unique + ) + .flatten + .map(Ok(_)) + + val count = PartialUtils.countDefined(edits) + count match { + case 0 => ZIO.fail(BadRequest(ApiError("No updates defined"))) + case 2 if edits.name.isDefined && edits.namespace.owner.isDefined => renameOp *> transferOp *> get + case 1 if edits.name.isDefined => renameOp *> get + case 1 if edits.namespace.owner.isDefined => transferOp *> get + case _ => renameOp *> transferOp *> update *> get + } + case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e))) + } + } + + def showProjectMembers( + projectOwner: String, + projectSlug: String, + limit: Option[Long], + offset: Long + ): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => + Members.membersAction(UserQueries.projectMembers(r.scope.id, _, _), limit, offset) + } + + def updateProjectMembers(projectOwner: String, projectSlug: String): Action[List[Members.MemberUpdate]] = + ApiAction(Permission.ManageProjectMembers, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[List[Members.MemberUpdate]]) { implicit r => + Members.updateMembers[Project, ProjectUserRole, ProjectRoleTable]( + getSubject = r.project, + allowOrgMembers = true, + getMembersQuery = UserQueries.projectMembers(r.scope.id, _, _), + createRole = ProjectUserRole(_, _, _), + roleCompanion = ProjectUserRole, + notificationType = NotificationType.ProjectInvite, + notificationLocalization = "notification.project.invite" + ) + } + + def showProjectStats( + projectOwner: String, + projectSlug: String, + fromDateString: String, + toDateString: String + ): Action[AnyContent] = + CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + import Ordering.Implicits._ + + def parseDate(dateStr: String) = + Validated + .catchOnly[DateTimeParseException](LocalDate.parse(dateStr)) + .leftMap(_ => ApiErrors(NonEmptyList.one(s"Badly formatted date $dateStr"))) + + for { + t <- ZIO + .fromEither(parseDate(fromDateString).product(parseDate(toDateString)).toEither) + .mapError(BadRequest(_)) + (fromDate, toDate) = t + _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) + res <- service.runDbCon( + ActionsAndStatsQueries + .projectStats(request.scope.id, fromDate, toDate) + .to[Vector] + .map(APIV2ProjectStatsQuery.asProtocol) + ) + } yield Ok(res.asJson) + } + + def setProjectVisibility(projectOwner: String, projectSlug: String): Action[EditVisibility] = + ApiAction(Permission.None, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[EditVisibility]) { implicit request => + request.project.flatMap { project => + request.body.process( + project, + request.user.get.id, + request.scopePermission, + Permission.DeleteProject, + service.insert(Job.UpdateDiscourseProjectTopic.newJob(project.id).toJob).unit, + doHardDeleteProject, + (newV, oldV) => + UserActionLogger + .logApi( + request, + LoggedActionType.ProjectVisibilityChange, + project.id, + newV, + oldV + )(LoggedActionProject.apply) + .unit + ) + } + } + + def projectData(projectOwner: String, projectSlug: String): Action[AnyContent] = + ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => + for { + project <- r.project + data <- ProjectData.of[ZIO[Blocking, Throwable, *]](project).orDie + } yield Ok( + Json.obj( + "flagCount" := data.flagCount, + "noteCount" := data.noteCount, + "lastVisibilityChange" := data.lastVisibilityChange.map { change => + Json.obj( + "comment" := change.comment + ) + }, + "lastVisibilityChangeUser" := data.lastVisibilityChangeUser + ) + ) + } + + private def doHardDeleteProject(project: Model[Project])(implicit request: ApiRequest[_, _]): UIO[Unit] = { + projects.delete(project).unit <* UserActionLogger.logApiOption( + request, + LoggedActionType.ProjectVisibilityChange, + None, + "deleted", + project.visibility.nameKey + )(LoggedActionProject.apply) + } + + def hardDeleteProject(projectOwner: String, projectSlug: String): Action[AnyContent] = + ApiAction(Permission.HardDeleteProject, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => + r.project.flatMap(doHardDeleteProject(_)).as(NoContent) + } + + def editProjectDiscourseSettings( + projectOwner: String, + projectSlug: String + ): Action[Projects.DiscourseModifyTopicSettings] = + ApiAction(Permission.EditAdminSettings, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[Projects.DiscourseModifyTopicSettings]) { implicit request => + request.project.flatMap { project => + val update = service.update(project)(_.copy(topicId = request.body.topicId, postId = request.body.postId)) + val addJob = service.insert(Job.UpdateDiscourseProjectTopic.newJob(project.id).toJob) + + update.as(NoContent) <* addJob.when(request.body.updateTopic) + } + } + + def redirectPluginId(pluginId: String, path: String): Action[AnyContent] = Action.asyncF { implicit request => + tryRedirectToNewUrls(pluginId, path) + } + + def tryRedirectToNewUrls(pluginId: String, path: String)( + implicit request: RequestHeader + ): ZIO[Any, Status, Result] = { + if (request.getQueryString("ore-dont-pluginid-redirect").contains("true")) { + ZIO.succeed(NotFound) + } else { + projects + .withPluginId(pluginId) + .get + .map { project => + val urlOwner = URLEncoder.encode(project.ownerName, "UTF-8") + val urlSlug = URLEncoder.encode(project.slug, "UTF-8") + Redirect( + s"/api/v2/projects/$urlOwner/$urlSlug/$path", + request.queryString ++ Map("ore-dont-pluginid-redirect" -> Seq("true")), + status = TEMPORARY_REDIRECT + ) + } + .orElseFail(NotFound) + } + } +} +object Projects { + import APIV2.{categoryCodec, visibilityCodec, permissionRoleCodec} + + @SnakeCaseJsonCodec case class PaginatedProjectResult( + pagination: Pagination, + result: Seq[APIV2.Project] + ) + + type EditableProject = EditableProjectF[Option] + case class EditableProjectF[F[_]]( + name: F[String], + namespace: EditableProjectNamespaceF[F], + category: F[Category], + summary: F[Option[String]], + settings: EditableProjectSettingsF[F] + ) + object EditableProjectF { + implicit val F + : TraverseKC[EditableProjectF] with RepresentableKC[EditableProjectF] { type RepresentationK[A] = Finite[12] } = + Derive.allKC[EditableProjectF, λ[A => Finite[12]]] + + val patchDecoder: EditableProjectF[PatchDecoder] = { + val namesWithImplicits: EditableProjectF[Tuple2K[Derive.Names, Decoder, *]] = + Derive.namesWithImplicitsC[EditableProjectF, Decoder] + PatchDecoder.fromName(namesWithImplicits)( + io.circe.derivation.renaming.snakeCase + ) + } + + def validation(implicit config: OreConfig): EditableProjectF[PartialUtils.Validator] = { + import PartialUtils.Validator + import PartialUtils.Validator._ + + EditableProjectF[Validator]( + checkLength("project name", config.ore.projects.maxDescLen), + EditableProjectNamespaceF[Validator](noValidation), + noValidation, + allValid(invaidIfEmpty("summary"), validIfEmpty(checkLength("summary", config.ore.projects.maxDescLen))), + EditableProjectSettingsF[Validator]( + allValid( + seq => Validated.condNel(seq.lengthIs > 5, seq, "Too many keywords provided"), + seq => Validated.condNel(seq.contains(""), seq, "Found keywords with empty strings"), + seq => Validated.condNel(seq.distinct == seq, seq, "Found duplicate keywords") + ), + invaidIfEmpty("homepage"), + invaidIfEmpty("issues"), + invaidIfEmpty("sources"), + invaidIfEmpty("support"), + EditableProjectLicenseF[Validator]( + invaidIfEmpty("license name"), + invaidIfEmpty("license url") + ), + noValidation + ) + ) + } + } + + case class EditableProjectNamespaceF[F[_]]( + owner: F[String] + ) + object EditableProjectNamespaceF { + implicit val F: TraverseKC[EditableProjectNamespaceF] with RepresentableKC[EditableProjectNamespaceF] { + type RepresentationK[A] = Finite[1] + } = + Derive.allKC[EditableProjectNamespaceF, λ[A => Finite[1]]] + } + + case class EditableProjectSettingsF[F[_]]( + keywords: F[List[String]], + homepage: F[Option[String]], + issues: F[Option[String]], + sources: F[Option[String]], + support: F[Option[String]], + license: EditableProjectLicenseF[F], + forumSync: F[Boolean] + ) + object EditableProjectSettingsF { + implicit val F: TraverseKC[EditableProjectSettingsF] with RepresentableKC[EditableProjectSettingsF] { + type RepresentationK[A] = Finite[8] + } = + Derive.allKC[EditableProjectSettingsF, λ[A => Finite[8]]] + } + + case class EditableProjectLicenseF[F[_]](name: F[Option[String]], url: F[Option[String]]) + object EditableProjectLicenseF { + implicit val F: TraverseKC[EditableProjectLicenseF] with RepresentableKC[EditableProjectLicenseF] { + type RepresentationK[A] = Finite[2] + } = + Derive.allKC[EditableProjectLicenseF, λ[A => Finite[2]]] + } + + @SnakeCaseJsonCodec case class ApiV2ProjectTemplate( + name: String, + pluginId: String, + category: Category, + description: Option[String], + ownerName: String + ) { + + def asFactoryTemplate: ProjectTemplate = ProjectTemplate(name, pluginId, category, description) + } + + @SnakeCaseJsonCodec case class DiscourseModifyTopicSettings( + topicId: Option[Int], + postId: Option[Int], + updateTopic: Boolean + ) +} diff --git a/apiV2/app/controllers/apiv2/Users.scala b/apiV2/app/controllers/apiv2/Users.scala new file mode 100644 index 000000000..a6793f725 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Users.scala @@ -0,0 +1,208 @@ +package controllers.apiv2 + +import play.api.http.HttpErrorHandler +import play.api.inject.ApplicationLifecycle +import play.api.mvc.{Action, AnyContent, Result} + +import controllers.OreControllerComponents +import controllers.apiv2.Users.{PaginatedCompactProjectResult, PaginatedUserResult, UserSortingStrategy} +import controllers.apiv2.helpers.{APIScope, ApiError, Pagination} +import db.impl.query.apiv2.{APIV2Queries, ActionsAndStatsQueries, UserQueries} +import models.protocols.APIV2 +import models.viewhelper.HeaderData +import ore.db.DbRef +import ore.models.project.ProjectSortingStrategy +import ore.models.user.User +import ore.permission.Permission +import ore.permission.role.Role + +import cats.syntax.all._ +import enumeratum.values.{StringEnum, StringEnumEntry} +import io.circe._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import io.circe.syntax._ +import zio.ZIO +import zio.interop.catz._ + +class Users( + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + + def listUsers( + q: Option[String], + minProjects: Option[Long], + roles: Seq[Role], + excludeOrganizations: Boolean, + sort: Option[UserSortingStrategy], + sortDescending: Boolean, + limit: Option[Long], + offset: Long + ): Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { + val realLimit = limitOrDefault(limit, config.ore.users.authorPageSize) + val realOffset = offsetOrZero(offset) + + val getUsers = service.runDbCon( + UserQueries + .userSearchQuery( + q, + minProjects.getOrElse(0L), + roles, + excludeOrganizations, + sort.getOrElse(UserSortingStrategy.Name), + sortDescending, + realLimit, + realOffset + ) + .to[Vector] + ) + + val userCount = service.runDbCon( + UserQueries + .userSearchCountQuery( + q, + minProjects.getOrElse(0L), + roles, + excludeOrganizations + ) + .unique + ) + + (getUsers <&> userCount).map { + case (users, userCount) => + Ok( + PaginatedUserResult( + Pagination(realLimit, realOffset, userCount), + users + ) + ) + } + } + + def showCurrentUser: Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { r => + r.user match { + case Some(user) => service.runDbCon(UserQueries.userQuery(user.name).unique).map(a => Ok(a.asJson)) + case None => ZIO.fail(Unauthorized(ApiError("Only user sessions for this endpoint"))) + } + } + + def showUser(user: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { + service.runDbCon(UserQueries.userQuery(user).option).map(_.fold(NotFound: Result)(a => Ok(a.asJson))) + } + + def showStarred( + user: String, + sort: Option[ProjectSortingStrategy], + limit: Option[Long], + offset: Long + ): Action[AnyContent] = + showUserAction( + user, + sort, + limit, + offset, + ActionsAndStatsQueries.starredQuery, + ActionsAndStatsQueries.starredCountQuery + ) + + def showWatching( + user: String, + sort: Option[ProjectSortingStrategy], + limit: Option[Long], + offset: Long + ): Action[AnyContent] = + showUserAction( + user, + sort, + limit, + offset, + ActionsAndStatsQueries.watchingQuery, + ActionsAndStatsQueries.watchingCountQuery + ) + + def showUserAction( + user: String, + sort: Option[ProjectSortingStrategy], + limit: Option[Long], + offset: Long, + query: ( + String, + Boolean, + Option[DbRef[User]], + ProjectSortingStrategy, + Long, + Long + ) => doobie.Query0[Either[DecodingFailure, APIV2.CompactProject]], + countQuery: (String, Boolean, Option[DbRef[User]]) => doobie.Query0[Long] + ): Action[AnyContent] = CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { implicit 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).flatMap(ZIO.foreach(_)(ZIO.fromEither(_))).orDie, service.runDbCon(countProjects)) + .parMapN { (projects, count) => + Ok( + PaginatedCompactProjectResult( + Pagination(realLimit, offset, count), + projects + ) + ) + } + } + + def getMemberships(user: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { + service.runDbCon(UserQueries.getMemberships(user).to[Vector]).map(r => Ok(r.asJson)) + } + + def showHeaderData(): Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { r => + HeaderData.of(r).map { headerData => + Ok( + Json.obj( + "hasNotice" := headerData.hasNotice, + "hasUnreadNotifications" := headerData.hasUnreadNotifications, + "unresolvedFlags" := headerData.unresolvedFlags, + "hasProjectApprovals" := headerData.hasProjectApprovals, + "hasReviewQueue" := headerData.hasReviewQueue, + "readPrompts" := r.user.get.readPrompts.map(_.value) + ) + ) + } + } +} +object Users { + @SnakeCaseJsonCodec case class PaginatedUserResult( + pagination: Pagination, + result: Seq[APIV2.User] + ) + + @SnakeCaseJsonCodec case class PaginatedCompactProjectResult( + pagination: Pagination, + result: Seq[APIV2.CompactProject] + ) + + sealed abstract class UserSortingStrategy(val value: String) extends StringEnumEntry + object UserSortingStrategy extends StringEnum[UserSortingStrategy] { + override def values: IndexedSeq[UserSortingStrategy] = findValues + + case object Name extends UserSortingStrategy("name") + case object Joined extends UserSortingStrategy("join_date") + case object Projects extends UserSortingStrategy("project_count") + } +} diff --git a/apiV2/app/controllers/apiv2/Versions.scala b/apiV2/app/controllers/apiv2/Versions.scala new file mode 100644 index 000000000..b507f4670 --- /dev/null +++ b/apiV2/app/controllers/apiv2/Versions.scala @@ -0,0 +1,546 @@ +package controllers.apiv2 + +import java.nio.file.Path +import java.time.format.DateTimeParseException +import java.time.{LocalDate, OffsetDateTime} + +import scala.jdk.CollectionConverters._ + +import play.api.http.HttpErrorHandler +import play.api.i18n.Lang +import play.api.inject.ApplicationLifecycle +import play.api.libs.Files +import play.api.mvc.{Action, AnyContent, MultipartFormData, Result} + +import controllers.OreControllerComponents +import controllers.apiv2.helpers._ +import controllers.sugar.Requests.ApiRequest +import db.impl.query.apiv2.{APIV2Queries, ActionsAndStatsQueries, VersionQueries} +import models.protocols.APIV2 +import models.querymodels.{APIV2QueryVersion, APIV2VersionStatsQuery} +import ore.OrePlatform +import ore.db.Model +import ore.db.access.ModelView +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.schema.{ProjectTable, VersionTable} +import ore.models.Job +import ore.models.project.Version.Stability +import ore.models.project._ +import ore.models.project.factory.ProjectFactory +import ore.models.project.io.{PluginFileWithData, PluginUpload, VersionedPlatform} +import ore.models.user.{LoggedActionType, LoggedActionVersion, User} +import ore.permission.Permission +import ore.permission.scope.ProjectScope +import util.syntax._ +import util.{PatchDecoder, UserActionLogger} + +import _root_.io.circe._ +import _root_.io.circe.derivation.annotations.SnakeCaseJsonCodec +import _root_.io.circe.syntax._ +import cats.Applicative +import cats.data.{Const => _, Tuple2K => _, _} +import cats.syntax.all._ +import doobie.free.connection.ConnectionIO +import perspective._ +import perspective.syntax.all._ +import perspective.macros.Derive +import zio.blocking.Blocking +import zio.interop.catz._ +import zio.{IO, UIO, ZIO} + +class Versions( + factory: ProjectFactory, + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents +) extends AbstractApiV2Controller(lifecycle) { + import Versions._ + + def listVersions( + projectOwner: String, + projectSlug: String, + platforms: Seq[String], + stability: Seq[Version.Stability], + releaseType: Seq[Version.ReleaseType], + limit: Option[Long], + offset: Long + ): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + val realLimit = limitOrDefault(limit, config.ore.projects.initVersionLoad.toLong) + val realOffset = offsetOrZero(offset) + val parsedPlatforms = platforms.map { s => + val splitted = s.split(":", 2) + (splitted(0), splitted.lift(1)) + }.toList + + val getVersions = VersionQueries + .versionQuery( + request.scope.id, + None, + parsedPlatforms, + stability.toList, + releaseType.toList, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id), + realLimit, + realOffset + ) + .to[Vector] + + val countVersions = VersionQueries + .versionCountQuery( + request.scope.id, + parsedPlatforms, + stability.toList, + releaseType.toList, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id) + ) + .unique + + (service.runDbCon(getVersions), service.runDbCon(countVersions)).parMapN { (versions, count) => + Ok( + PaginatedVersionResult( + Pagination(realLimit, realOffset, count), + versions + ) + ) + } + } + + def showVersionAction(projectOwner: String, projectSlug: String, name: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + implicit request => + service + .runDbCon( + VersionQueries + .singleVersionQuery( + request.scope.id, + name, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id) + ) + .option + ) + .get + .orElseFail(NotFound) + .map(a => Ok(a.asJson)) + } + + def editVersion(projectOwner: String, projectSlug: String, name: String): Action[Json] = + ApiAction(Permission.EditVersion, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF(parseCirce.json) { + implicit request => + val root = request.body.hcursor + import cats.instances.list._ + + def parsePlatforms(platforms: List[SimplePlatform]) = { + platforms.distinct + .traverse { + case SimplePlatform(platformName, platformVersion) => + config.ore.platformsByName + .get(platformName) + .toValidNel(s"Don't know about the platform named $platformName") + .tupleRight(platformVersion) + } + .map { ps => + ps.traverse { + case (platform, version) => + OrePlatform + .produceVersionWarning(platform)(version) + .as( + VersionedPlatform( + platform.name, + version, + version.map(OrePlatform.coarseVersionOf(platform)) + ) + ) + + } + } + .nested + .value + } + + //We take the platform as flat in the API, but want it columnar. + //We also want to verify the version and platform name, and get a coarse version + val res: ValidatedNel[String, Writer[List[String], (DbEditableVersion, Option[List[VersionedPlatform]])]] = + EditableVersionF.patchDecoder + .traverseKC( + λ[PatchDecoder ~>: Compose2[Decoder.AccumulatingResult, Option, *]](_.decode(root)) + ) + .leftMap(_.map(_.show)) + .andThen { a => + a.platforms + .traverse(parsePlatforms) + .map(_.sequence) + .tupleLeft(a) + } + .map { + case (a, w) => + w.map { optPlatforms => + val version = DbEditableVersionF[Option]( + a.stability, + a.releaseType + ) + + version -> optPlatforms + } + } + + res match { + case Validated.Valid(WriterT((warnings, (version, platforms)))) => + val versionIdQuery = for { + v <- TableQuery[VersionTable] if v.projectId === request.scope.id && v.versionString === name + } yield v.id + + service.runDBIO(versionIdQuery.result.head).flatMap { versionId => + val handlePlatforms = platforms.fold(ZIO.unit) { platforms => + val deleteAll = service.deleteWhere(VersionPlatform)(_.versionId === versionId) + val insertNew = service + .bulkInsert(platforms.map(p => VersionPlatform(versionId, p.id, p.version, p.coarseVersion))) + .unit + + deleteAll *> insertNew + } + + val needEdit = + version.foldLeftKC(false)(acc => Lambda[Option ~>: Const[Boolean, *]](op => acc || op.isDefined)) + val doEdit = + if (!needEdit) Applicative[ConnectionIO].unit + else VersionQueries.updateVersion(request.scope.id, name, version).run.void + + handlePlatforms *> service + .runDbCon( + //We need two queries as we use the generic update function + doEdit *> VersionQueries + .singleVersionQuery( + request.scope.id, + name, + request.globalPermissions.has(Permission.SeeHidden), + request.user.map(_.id) + ) + .unique + ) + .map(r => Ok(WithAlerts(r, warnings = warnings))) + } + case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e))) + } + } + + def showVersionChangelog(projectOwner: String, projectSlug: String, name: String): Action[AnyContent] = + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service + .runDBIO( + TableQuery[VersionTable] + .filter(v => v.projectId === request.scope.id && v.versionString === name) + .map(_.description) + .result + .headOption + ) + .map(_.fold(NotFound: Result)(a => Ok(APIV2.VersionChangelog(a)))) + } + + def updateChangelog(projectOwner: String, projectSlug: String, name: String): Action[APIV2.VersionChangelog] = + ApiAction(Permission.EditVersion, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[APIV2.VersionChangelog]) { implicit request => + for { + version <- request.version(name) + oldDescription = version.description.getOrElse("") + newDescription = request.body.changelog.trim + _ <- if (newDescription.length < Page.maxLength) ZIO.unit + else ZIO.fail(BadRequest(ApiError("Description too long"))) + _ <- service.update(version)(_.copy(description = Some(newDescription))) + _ <- service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob) + _ <- UserActionLogger.logApi( + request, + LoggedActionType.VersionDescriptionEdited, + version.id, + newDescription, + oldDescription + )(LoggedActionVersion(_, Some(version.projectId))) + } yield NoContent + } + + def showVersionStats( + projectOwner: String, + projectSlug: String, + version: String, + fromDateString: String, + toDateString: String + ): Action[AnyContent] = + CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + import Ordering.Implicits._ + + def parseDate(dateStr: String) = + Validated + .catchOnly[DateTimeParseException](LocalDate.parse(dateStr)) + .leftMap(_ => ApiErrors(NonEmptyList.one(s"Badly formatted date $dateStr"))) + + for { + t <- ZIO + .fromEither(parseDate(fromDateString).product(parseDate(toDateString)).toEither) + .mapError(BadRequest(_)) + (fromDate, toDate) = t + _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) + res <- service.runDbCon( + ActionsAndStatsQueries + .versionStats(request.scope.id, version, fromDate, toDate) + .to[Vector] + .map(APIV2VersionStatsQuery.asProtocol) + ) + } yield Ok(res.asJson) + } + + //TODO: Do the async part at some point + private def readFileAsync(file: Path): ZIO[Blocking, Throwable, String] = { + import zio.blocking._ + effectBlocking(java.nio.file.Files.readAllLines(file).asScala.mkString("\n")) + } + + private def processVersionUploadToErrors( + implicit request: ApiRequest[ProjectScope, MultipartFormData[Files.TemporaryFile]] + ): ZIO[Blocking, Result, (Model[User], Model[Project], PluginFileWithData)] = { + val fileF = ZIO.fromEither( + request.body.file("plugin-file").toRight(BadRequest(ApiError("No plugin file specified"))) + ) + + for { + user <- ZIO.fromOption(request.user).orElseFail(BadRequest(ApiError("No user found for session"))) + project <- request.project + file <- fileF + pluginFile <- factory + .collectErrorsForVersionUpload(PluginUpload(file.ref, file.filename), user, project) + .leftMap { s => + implicit val lang: Lang = user.langOrDefault + BadRequest(UserError(messagesApi(s))) + } + } yield (user, project, pluginFile) + } + + def scanVersion(projectOwner: String, projectSlug: String): Action[MultipartFormData[Files.TemporaryFile]] = + ApiAction(Permission.CreateVersion, APIScope.ProjectScope(projectOwner, projectSlug))( + parse.multipartFormData(config.ore.projects.uploadMaxSize.toBytes) + ).asyncF { implicit request => + for { + t <- processVersionUploadToErrors + (user, _, pluginFile) = t + } yield { + val apiVersion = APIV2QueryVersion( + OffsetDateTime.now(), + pluginFile.versionString, + pluginFile.dependencyIds.toList, + pluginFile.dependencyVersions.toList, + Visibility.Public, + 0, + pluginFile.fileSize, + pluginFile.md5, + pluginFile.pluginFileName, + Some(user.name), + ReviewState.Unreviewed, + pluginFile.entries.exists(_.mixin), + Version.Stability.Stable, + None, + pluginFile.versionedPlatforms.map(_.id), + pluginFile.versionedPlatforms.map(_.version), + None + ) + + val warnings = NonEmptyList.fromList(pluginFile.warnings.toList) + Ok(ScannedVersion(apiVersion.asProtocol, warnings)) + } + } + + def deployVersion(projectOwner: String, projectSlug: String): Action[MultipartFormData[Files.TemporaryFile]] = + ApiAction(Permission.CreateVersion, APIScope.ProjectScope(projectOwner, projectSlug))( + parse.multipartFormData(config.ore.projects.uploadMaxSize.toBytes) + ).asyncF { implicit request => + type TempFile = MultipartFormData.FilePart[Files.TemporaryFile] + import zio.blocking._ + + val pluginInfoFromFileF = ZIO.bracket( + acquire = UIO(request.body.file("plugin-info")).get.mapError(Left.apply), + release = (filePart: TempFile) => effectBlocking(java.nio.file.Files.deleteIfExists(filePart.ref)).fork, + use = (filePart: TempFile) => readFileAsync(filePart.ref).mapError(Right.apply) + ) + + val dataStringF = ZIO + .fromOption(request.body.dataParts.get("plugin-info").flatMap(_.headOption)) + .orElse(pluginInfoFromFileF) + .catchAll { + case Left(_) => IO.fail("No plugin info specified") + case Right(e) => IO.die(e) + } + + val dataF = dataStringF + .flatMap(s => ZIO.fromEither(parser.decode[DeployVersionInfo](s).leftMap(_.show))) + .ensure("Description too long")(_.description.forall(_.length < Page.maxLength)) + .mapError(e => BadRequest(ApiError(e))) + + for { + t <- processVersionUploadToErrors + (user, project, pluginFile) = t + data <- dataF + t <- factory + .createVersion( + project, + pluginFile, + data.description, + data.createForumPost.getOrElse(project.settings.forumSync), + data.stability.getOrElse(Stability.Stable), + data.releaseType + ) + .mapError { es => + implicit val lang: Lang = user.langOrDefault + BadRequest(UserErrors(es.map(messagesApi(_)))) + } + } yield { + val (_, version, platforms) = t + + val apiVersion = APIV2QueryVersion( + version.createdAt, + version.versionString, + version.dependencyIds, + version.dependencyVersions, + version.visibility, + 0, + version.fileSize, + version.hash, + version.fileName, + Some(user.name), + version.reviewState, + version.tags.usesMixin, + version.tags.stability, + version.tags.releaseType, + platforms.map(_.platform).toList, + platforms.map(_.platformVersion).toList, + version.postId + ) + + Created(apiVersion.asProtocol).withHeaders( + "Location" -> routes.Versions + .showVersionAction(project.ownerName, project.slug, version.versionString) + .absoluteURL() + ) + } + } + + def hardDeleteVersion(projectOwner: String, projectSlug: String, version: String): Action[AnyContent] = + ApiAction(Permission.HardDeleteVersion, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + implicit request => + request.version(version).flatMap { version => + val log = UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + "", + "" + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + + log *> projects.deleteVersion(version).as(NoContent) + } + } + + def setVersionVisibility(projectOwner: String, projectSlug: String, version: String): Action[EditVisibility] = + ApiAction(Permission.None, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[EditVisibility]) { implicit request => + request.version(version).flatMap { version => + request.body.process( + version, + request.user.get.id, + request.scopePermission, + Permission.DeleteVersion, + service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob).unit, + projects.deleteVersion(_: Model[Version]).unit, + (newV, oldV) => + UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + newV, + oldV + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + ) + } + } + + def editVersionDiscourseSettings( + projectOwner: String, + projectSlug: String, + version: String + ): Action[Versions.DiscourseModifyPostSettings] = + ApiAction(Permission.EditAdminSettings, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[Versions.DiscourseModifyPostSettings]) { implicit request => + request.version(version).flatMap { version => + val update = service.update(version)(_.copy(postId = request.body.postId)) + val addJob = service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob) + + update.as(NoContent) <* addJob.when(request.body.updatePost) + } + } +} +object Versions { + + //TODO: Allow setting multiple platforms + @SnakeCaseJsonCodec case class DeployVersionInfo( + createForumPost: Option[Boolean], + description: Option[String], + stability: Option[Version.Stability], + releaseType: Option[Version.ReleaseType] + ) + + @SnakeCaseJsonCodec case class PaginatedVersionResult( + pagination: Pagination, + result: Seq[APIV2.Version] + ) + + @SnakeCaseJsonCodec case class SimplePlatform( + platform: String, + platformVersion: Option[String] + ) + + type EditableVersion = EditableVersionF[Option] + type DbEditableVersion = DbEditableVersionF[Option] + case class EditableVersionF[F[_]]( + stability: F[Version.Stability], + releaseType: F[Option[Version.ReleaseType]], + platforms: F[List[SimplePlatform]] + ) + object EditableVersionF { + implicit val F + : TraverseKC[EditableVersionF] with RepresentableKC[EditableVersionF] { type RepresentationK[A] = Finite[3] } = + Derive.allKC[EditableVersionF, λ[A => Finite[3]]] + + val patchDecoder: EditableVersionF[PatchDecoder] = { + val namesWithImplicits: EditableVersionF[Tuple2K[Derive.Names, Decoder, *]] = + Derive.namesWithImplicitsC[EditableVersionF, Decoder] + PatchDecoder.fromName(namesWithImplicits)( + _root_.io.circe.derivation.renaming.snakeCase + ) + } + } + + case class DbEditableVersionF[F[_]]( + stability: F[Version.Stability], + releaseType: F[Option[Version.ReleaseType]] + ) + object DbEditableVersionF { + implicit val F: TraverseKC[DbEditableVersionF] with RepresentableKC[DbEditableVersionF] { + type RepresentationK[A] = Finite[2] + } = Derive.allKC[DbEditableVersionF, λ[A => Finite[2]]] + } + + @SnakeCaseJsonCodec case class ScannedVersion( + version: APIV2.Version, + warnings: Option[NonEmptyList[String]] + ) + + @SnakeCaseJsonCodec case class DiscourseModifyPostSettings( + postId: Option[Int], + updatePost: Boolean + ) +} diff --git a/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala b/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala new file mode 100644 index 000000000..b9c681cdb --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala @@ -0,0 +1,60 @@ +package controllers.apiv2.helpers + +import play.api.http.Writeable +import play.api.mvc.Result + +import models.protocols.APIV2.visibilityCodec +import ore.db.impl.common.Hideable +import ore.db.{DbRef, Model} +import ore.models.project.Visibility +import ore.models.user.User +import ore.permission.Permission +import ore.syntax._ + +import io.circe.Json +import io.circe.syntax._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import zio.{IO, UIO, ZIO} + +@SnakeCaseJsonCodec case class EditVisibility( + visibility: Visibility, + comment: String +) { + + def process[A]( + toChange: Model[A], + changer: DbRef[User], + scopePerms: Permission, + deletePerm: Permission, + insertDiscourseUpdateJob: UIO[Unit], + doHardDelete: Model[A] => UIO[Unit], + createLog: (String, String) => UIO[Unit] + )(implicit jsonWrite: Writeable[Json], hide: Hideable[UIO, A]): ZIO[Any, Result, Result] = { + import play.api.mvc.Results._ + val forumVisbility = + if (Visibility.isPublic(visibility) != Visibility.isPublic(toChange.hVisibility)) + insertDiscourseUpdateJob + else IO.unit + + val nonReviewerChecks = visibility match { + case Visibility.NeedsApproval => + val cond = toChange.hVisibility == Visibility.NeedsChanges && + scopePerms.has(Permission.EditProjectSettings) + if (cond) ZIO.unit + else ZIO.fail(Forbidden) + case Visibility.SoftDelete => + if (scopePerms.has(deletePerm)) ZIO.unit else ZIO.fail(Forbidden) + case v => ZIO.fail(BadRequest(Json.obj("error" := s"Project can't be changed to $v"))) + } + + val permChecks = if (scopePerms.has(Permission.Reviewer)) ZIO.unit else nonReviewerChecks + + val projectAction = + if (toChange.hVisibility == Visibility.New) doHardDelete(toChange) + else toChange.setVisibility(visibility, comment, changer) + + val log = createLog(visibility.nameKey, toChange.hVisibility.nameKey) + + permChecks *> (forumVisbility <&> projectAction) *> log.as(NoContent) + } +} diff --git a/apiV2/app/controllers/apiv2/helpers/Members.scala b/apiV2/app/controllers/apiv2/helpers/Members.scala new file mode 100644 index 000000000..169f9bffa --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/Members.scala @@ -0,0 +1,140 @@ +package controllers.apiv2.helpers + +import play.api.http.Writeable +import play.api.mvc.Result + +import controllers.sugar.Requests.ApiRequest +import models.protocols.APIV2 +import ore.data.user.notification.NotificationType +import ore.db.{DbRef, Model, ModelCompanion, ModelQuery, ModelService} +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.user.{Notification, User, UserOwned} +import ore.models.user.role.{ProjectUserRole, UserRoleModel} +import ore.permission.Permission +import ore.util.OreMDC +import util.syntax._ + +import cats.data.NonEmptyList +import cats.syntax.all._ +import zio.{IO, UIO, ZIO} +import zio.interop.catz._ +import play.api.mvc.Results._ + +import db.impl.access.UserBase +import ore.db.access.ModelView +import ore.db.impl.common.Named +import ore.db.impl.table.common.RoleTable +import ore.member.MembershipDossier +import ore.models.organization.Organization +import ore.permission.role.Role + +import io.circe._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import io.circe.syntax._ + +object Members { + + import APIV2.permissionRoleCodec + + @SnakeCaseJsonCodec case class MemberUpdate( + user: String, + role: ore.permission.role.Role + ) + + protected def limitOrDefault(limit: Option[Long], default: Long): Long = math.min(limit.getOrElse(default), default) + protected def offsetOrZero(offset: Long): Long = math.max(offset, 0) + + def membersAction( + getMembersQuery: (Long, Long) => doobie.Query0[APIV2.Member], + limit: Option[Long], + offset: Long + )( + implicit r: ApiRequest[_, _], + service: ModelService[UIO], + writeJson: Writeable[Json] + ): ZIO[Any, Nothing, Result] = { + service + .runDbCon(getMembersQuery(limitOrDefault(limit, 25), offsetOrZero(offset)).to[Vector]) + .map { xs => + val users = + if (r.scopePermission.has(Permission.ManageSubjectMembers)) xs + else xs.filter(_.role.isAccepted) + + Ok(users.asJson) + } + } + + private def check(test: Boolean, errorMessage: String)(implicit writeError: Writeable[ApiError]) = + if (test) ZIO.fail(BadRequest(ApiError(errorMessage))) else ZIO.unit + + def updateMembers[A <: Named: UserOwned, R <: UserRoleModel[R], RT <: RoleTable[R]]( + getSubject: IO[Result, Model[A]], + allowOrgMembers: Boolean, + getMembersQuery: (Long, Long) => doobie.Query0[APIV2.Member], + createRole: (DbRef[User], DbRef[User], Role) => R, + roleCompanion: ModelCompanion[R], + notificationType: NotificationType, + notificationLocalization: String + )( + implicit r: ApiRequest[_, List[MemberUpdate]], + service: ModelService[UIO], + users: UserBase[UIO], + memberships: MembershipDossier.Aux[UIO, A, R, RT], + modelQuery: ModelQuery[R], + writeJson: Writeable[Json], + writeError: Writeable[ApiError] + ): ZIO[Any, Result, Result] = + for { + subject <- getSubject + resolvedUsers <- ZIO.foreach(r.body) { m => + users.withName(m.user)(OreMDC.NoMDC).tupleLeft(m.role).value.someOrFail(NotFound) + } + _ <- if (!allowOrgMembers) { + val existOrgUserRep = + resolvedUsers.map(_._2.toMaybeOrganization(ModelView.later(Organization))).foldLeft(false: Rep[Boolean]) { + _ || _.exists + } + + for { + existOrgUser <- service.runDBIO(Query(existOrgUserRep).result.head) + _ <- check(existOrgUser, "Can't add organization as a member") + } yield () + } else ZIO.unit + currentMembers <- memberships.members(subject) + resolvedUsersMap = resolvedUsers.map(t => t._2.id.value -> t._1).toMap + currentMembersMap = currentMembers.map(t => t.userId -> t).toMap + newUsers = resolvedUsersMap.view.filterKeys(!currentMembersMap.contains(_)).toMap + usersToUpdate = currentMembersMap.collect { + case (user, oldRole) if resolvedUsersMap.get(user).exists(newRole => oldRole.role != newRole) => + (user, (oldRole, resolvedUsersMap(user))) + } + usersToDelete = currentMembersMap.view.filterKeys(!resolvedUsersMap.contains(_)).toMap + + permChangesUpdate = usersToUpdate.map(t => t._2._1.role.permissions ++ t._2._2.permissions) + permChangesDelete = usersToDelete.map(t => t._2.role.permissions) + permChanges = Permission((permChangesUpdate ++ permChangesDelete).toSeq: _*) + + _ <- check(usersToUpdate.contains(subject.userId), "Can't update subject") + _ <- check(usersToDelete.contains(subject.userId), "Can't delete subject") + // No matter if you're the owner or otherwise, this is the wrong place to set the owner role + _ <- check(permChanges.has(Permission.IsSubjectOwner), "Can't edit owner roles") + _ <- check(!r.scopePermission.has(permChanges), "Can't edit roles higher than yourself") + _ <- service.bulkInsert(newUsers.map(t => createRole(t._1, subject.id, t._2)).toSeq) + _ <- ZIO.foreach(usersToUpdate.values)(t => service.update(t._1)(_.withRole(t._2))) + _ <- service.deleteWhere(roleCompanion)(_.id.inSetBind(usersToDelete.values.map(_.id.value))) + _ <- { + val notifications = newUsers.map { + case (userId, role) => + Notification( + userId = userId, + originId = Some(subject.userId), + notificationType = notificationType, + messageArgs = NonEmptyList.of(notificationLocalization, role.title, subject.name) + ) + } + + service.bulkInsert(notifications.toSeq) + } + res <- membersAction(getMembersQuery, None, 0) + } yield res +} diff --git a/apiV2/app/controllers/apiv2/helpers/Pagination.scala b/apiV2/app/controllers/apiv2/helpers/Pagination.scala new file mode 100644 index 000000000..b896b4994 --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/Pagination.scala @@ -0,0 +1,9 @@ +package controllers.apiv2.helpers + +import io.circe.derivation.annotations.SnakeCaseJsonCodec + +@SnakeCaseJsonCodec case class Pagination( + limit: Long, + offset: Long, + count: Long +) diff --git a/apiV2/app/controllers/apiv2/helpers/WithAlerts.scala b/apiV2/app/controllers/apiv2/helpers/WithAlerts.scala new file mode 100644 index 000000000..5ac8c6e0e --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/WithAlerts.scala @@ -0,0 +1,29 @@ +package controllers.apiv2.helpers + +import io.circe.{Encoder, Json} +import io.circe.syntax._ + +case class WithAlerts[A]( + obj: A, + errors: Seq[String] = Nil, + success: Seq[String] = Nil, + info: Seq[String] = Nil, + warnings: Seq[String] = Nil +) +object WithAlerts { + + implicit def encoder[A: Encoder.AsObject]: Encoder[WithAlerts[A]] = (a: WithAlerts[A]) => { + def noneIfEmpty(xs: Seq[String]): Option[Seq[String]] = if (xs.isEmpty) None else Some(xs) + + val alerts = Json.obj( + "alerts" := Json.obj( + "errors" := noneIfEmpty(a.errors), + "success" := noneIfEmpty(a.success), + "info" := noneIfEmpty(a.info), + "warnings" := noneIfEmpty(a.warnings) + ) + ) + + alerts.deepDropNullValues.deepMerge(a.obj.asJson) + } +} diff --git a/apiV2/app/controllers/apiv2/helpers/apiScope.scala b/apiV2/app/controllers/apiv2/helpers/apiScope.scala new file mode 100644 index 000000000..e3383a52b --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/apiScope.scala @@ -0,0 +1,68 @@ +package controllers.apiv2.helpers + +import scala.collection.immutable + +import models.protocols.APIV2 +import ore.db.ModelService +import ore.db.impl.schema.{OrganizationTable, ProjectTable, UserTable} +import ore.permission.scope.Scope +import ore.permission.scope.{ + GlobalScope => RealGlobalScope, + OrganizationScope => RealOrganizationScope, + ProjectScope => RealProjectScope +} + +import enumeratum.{Enum, EnumEntry} +import io.circe._ +import ore.db.impl.OrePostgresDriver.api._ +import zio.{IO, UIO} + +sealed abstract class APIScope[+RealScope <: Scope](val tpe: APIScopeType) { + + //Proper GADT support please + def toRealScope(implicit service: ModelService[UIO]): IO[Unit, RealScope] = (this: APIScope[Scope]) match { + case APIScope.GlobalScope => UIO.succeed(RealGlobalScope.asInstanceOf[RealScope]) + case APIScope.ProjectScope(projectOwner, projectSlug) => + service + .runDBIO( + TableQuery[ProjectTable] + .filter(p => p.ownerName === projectOwner && p.slug.toLowerCase === projectSlug.toLowerCase) + .map(_.id) + .result + .headOption + ) + .get + .orElseFail(()) + .map(RealProjectScope(_).asInstanceOf[RealScope]) + case APIScope.OrganizationScope(organizationName) => + val q = for { + u <- TableQuery[UserTable] + if u.name === organizationName + o <- TableQuery[OrganizationTable] if u.id === o.id + } yield o.id + + service + .runDBIO(q.result.headOption) + .get + .orElseFail(()) + .map(RealOrganizationScope(_).asInstanceOf[RealScope]) + } +} +object APIScope { + case object GlobalScope extends APIScope[RealGlobalScope.type](APIScopeType.Global) + case class ProjectScope(projectOwner: String, projectSlug: String) + extends APIScope[RealProjectScope](APIScopeType.Project) + case class OrganizationScope(organizationName: String) + extends APIScope[RealOrganizationScope](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 codec: Codec[APIScopeType] = APIV2.enumCodec(APIScopeType)(_.entryName) +} diff --git a/apiV2/app/controllers/apiv2/helpers/errors.scala b/apiV2/app/controllers/apiv2/helpers/errors.scala new file mode 100644 index 000000000..2713060c7 --- /dev/null +++ b/apiV2/app/controllers/apiv2/helpers/errors.scala @@ -0,0 +1,14 @@ +package controllers.apiv2.helpers + +import cats.data.NonEmptyList +import cats.kernel.Semigroup +import io.circe.derivation.annotations.SnakeCaseJsonCodec + +@SnakeCaseJsonCodec case class ApiError(error: String) +@SnakeCaseJsonCodec case class ApiErrors(errors: NonEmptyList[String]) +object ApiErrors { + implicit val semigroup: Semigroup[ApiErrors] = (x: ApiErrors, y: ApiErrors) => ApiErrors(x.errors.concatNel(y.errors)) +} + +@SnakeCaseJsonCodec case class UserError(userError: String) +@SnakeCaseJsonCodec case class UserErrors(userErrors: NonEmptyList[String]) diff --git a/apiV2/app/db/impl/query/APIV2Queries.scala b/apiV2/app/db/impl/query/APIV2Queries.scala deleted file mode 100644 index 91ed78450..000000000 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ /dev/null @@ -1,460 +0,0 @@ -package db.impl.query - -import scala.language.higherKinds - -import java.sql.Timestamp -import java.time.{LocalDate, LocalDateTime} - -import play.api.mvc.RequestHeader - -import controllers.sugar.Requests.ApiAuthInfo -import models.protocols.APIV2 -import models.querymodels._ -import ore.OreConfig -import ore.data.project.Category -import ore.db.DbRef -import ore.models.api.ApiKey -import ore.models.project.io.ProjectFiles -import ore.models.project.{ProjectSortingStrategy, TagColor} -import ore.models.user.User -import ore.permission.Permission - -import cats.Reducible -import cats.data.NonEmptyList -import cats.syntax.all._ -import doobie._ -import doobie.implicits._ -import doobie.postgres.implicits._ -import doobie.implicits.javasql._ -import doobie.implicits.javatime.JavaTimeLocalDateMeta -import doobie.postgres.circe.jsonb.implicits._ -import doobie.util.Put -import io.circe.DecodingFailure -import zio.ZIO -import zio.blocking.Blocking - -object APIV2Queries extends WebDoobieOreProtocol { - - implicit val apiV2TagRead: Read[List[APIV2QueryVersionTag]] = - viewTagListRead.map(_.map(t => APIV2QueryVersionTag(t.name, t.data, t.color))) - implicit val apiV2TagWrite: Write[List[APIV2QueryVersionTag]] = - viewTagListWrite.contramap(_.map(t => ViewTag(t.name, t.data, t.color))) - - implicit val apiV2TagOptRead: Read[Option[List[APIV2QueryVersionTag]]] = - Read[(Option[List[String]], Option[List[Option[String]]], Option[List[TagColor]])].map { - case (Some(name), Some(data), Some(color)) => - Some(name.zip(data).zip(color).map { case ((n, d), c) => APIV2QueryVersionTag(n, d, c) }) - case _ => None - } - - def getApiAuthInfo(token: String): Query0[ApiAuthInfo] = - sql"""|SELECT u.id, - | u.created_at, - | u.full_name, - | u.name, - | u.email, - | u.tagline, - | u.join_date, - | u.read_prompts, - | u.is_locked, - | u.language, - | ak.name, - | ak.owner_id, - | ak.token, - | ak.raw_key_permissions, - | aks.token, - | aks.expires, - | CASE - | WHEN u.id IS NULL THEN 1::BIT(64) - | ELSE (coalesce(gt.permission, B'0'::BIT(64)) | 1::BIT(64) | (1::BIT(64) << 1) | (1::BIT(64) << 2)) & - | coalesce(ak.raw_key_permissions, (-1)::BIT(64)) - | END - | FROM api_sessions aks - | LEFT JOIN api_keys ak ON aks.key_id = ak.id - | LEFT JOIN users u ON aks.user_id = u.id - | LEFT JOIN global_trust gt ON gt.user_id = u.id - | WHERE aks.token = $token""".stripMargin.query[ApiAuthInfo] - - def findApiKey(identifier: String, token: String): Query0[(DbRef[ApiKey], DbRef[User])] = - sql"""SELECT k.id, k.owner_id FROM api_keys k WHERE k.token_identifier = $identifier AND k.token = crypt($token, k.token)""" - .query[(DbRef[ApiKey], DbRef[User])] - - def createApiKey( - name: String, - ownerId: DbRef[User], - tokenIdentifier: String, - token: String, - perms: Permission - ): doobie.Update0 = - sql"""|INSERT INTO api_keys (created_at, name, owner_id, token_identifier, token, raw_key_permissions) - |VALUES (now(), $name, $ownerId, $tokenIdentifier, crypt($token, gen_salt('bf')), $perms)""".stripMargin.update - - def deleteApiKey(name: String, ownerId: DbRef[User]): doobie.Update0 = - sql"""DELETE FROM api_keys k WHERE k.name = $name AND k.owner_id = $ownerId""".update - - //Like in, but takes a tuple - def in2[F[_]: Reducible, A: Put, B: Put](f: Fragment, fs: F[(A, B)]): Fragment = - fs.toList.map { case (a, b) => fr0"($a, $b)" }.foldSmash1(f ++ fr0"IN (", fr",", fr")") - - def projectSelectFrag( - pluginId: Option[String], - category: List[Category], - tags: List[(String, Option[String])], - query: Option[String], - owner: Option[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Fragment = { - val userActionsTaken = currentUserId.fold(fr"FALSE, FALSE,") { id => - fr"""|EXISTS(SELECT * FROM project_stars s WHERE s.project_id = p.id AND s.user_id = $id) AS user_stared, - |EXISTS(SELECT * FROM project_watchers s WHERE s.project_id = p.id AND s.user_id = $id) AS user_watching,""".stripMargin - } - - val base = - sql"""|SELECT p.created_at, - | p.plugin_id, - | p.name, - | p.owner_name, - | p.slug, - | p.promoted_versions, - | p.views, - | p.downloads, - | p.recent_views, - | p.recent_downloads, - | p.stars, - | p.watchers, - | p.category, - | p.description, - | COALESCE(p.last_updated, p.created_at) AS last_updated, - | p.visibility,""".stripMargin ++ userActionsTaken ++ - fr"""| ps.homepage, - | ps.issues, - | ps.source, - | ps.support, - | ps.license_name, - | ps.license_url, - | ps.forum_sync - | FROM home_projects p - | JOIN projects ps ON p.id = ps.id""".stripMargin - - val visibilityFrag = - if (canSeeHidden) None - else - currentUserId.fold(Some(fr"(p.visibility = 1)")) { id => - Some(fr"(p.visibility = 1 OR ($id = ANY(p.project_members) AND p.visibility != 5))") - } - - val (tagsWithData, tagsWithoutData) = tags.partitionEither { - case (name, Some(data)) => Left((name, data)) - case (name, None) => Right(name) - } - - val filters = Fragments.whereAndOpt( - pluginId.map(id => fr"p.plugin_id = $id"), - NonEmptyList.fromList(category).map(Fragments.in(fr"p.category", _)), - if (tags.nonEmpty) { - val jsSelect = - sql"""|SELECT pv.tag_name - | FROM jsonb_to_recordset(p.promoted_versions) AS pv(tag_name TEXT, tag_version TEXT) """.stripMargin ++ - Fragments.whereAndOpt( - NonEmptyList.fromList(tagsWithData).map(t => in2(fr"(pv.tag_name, pv.tag_version)", t)), - NonEmptyList.fromList(tagsWithoutData).map(t => Fragments.in(fr"pv.tag_name", t)) - ) - - Some(fr"EXISTS" ++ Fragments.parentheses(jsSelect)) - } else - None, - query.map { q => - val trimmedQ = q.trim - - if (q.endsWith(" ")) fr"p.search_words @@ websearch_to_tsquery('english', $trimmedQ)" - else fr"p.search_words @@ websearch_to_tsquery_postfix('english', $trimmedQ)" - }, - owner.map(o => fr"p.owner_name = $o"), - visibilityFrag - ) - - base ++ filters - } - - def projectQuery( - pluginId: Option[String], - category: List[Category], - tags: List[(String, Option[String])], - query: Option[String], - owner: Option[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]], - order: ProjectSortingStrategy, - orderWithRelevance: Boolean, - limit: Long, - offset: Long - )( - implicit projectFiles: ProjectFiles[ZIO[Blocking, Nothing, *]], - requestHeader: RequestHeader, - config: OreConfig - ): Query0[ZIO[Blocking, Nothing, APIV2.Project]] = { - val ordering = if (orderWithRelevance && query.nonEmpty) { - val relevance = query.fold(fr"1") { q => - val trimmedQ = q.trim - - if (q.endsWith(" ")) fr"ts_rank(p.search_words, websearch_to_tsquery('english', $trimmedQ)) DESC" - else fr"ts_rank(p.search_words, websearch_to_tsquery_postfix('english', $trimmedQ)) DESC" - } - - // 1483056000 is the Ore epoch - // 86400 seconds to days - // 604800‬ seconds to weeks - order match { - case ProjectSortingStrategy.MostStars => fr"p.stars *" ++ relevance - case ProjectSortingStrategy.MostDownloads => fr"(p.downloads / 100) *" ++ relevance - case ProjectSortingStrategy.MostViews => fr"(p.views / 200) *" ++ relevance - case ProjectSortingStrategy.Newest => - fr"((EXTRACT(EPOCH FROM p.created_at) - 1483056000) / 86400) *" ++ relevance - case ProjectSortingStrategy.RecentlyUpdated => - fr"((EXTRACT(EPOCH FROM p.last_updated) - 1483056000) / 604800) *" ++ relevance - case ProjectSortingStrategy.OnlyRelevance => relevance - case ProjectSortingStrategy.RecentViews => fr"p.recent_views *" ++ relevance - case ProjectSortingStrategy.RecentDownloads => fr"p.recent_downloads*" ++ relevance - } - } else order.fragment - - val select = projectSelectFrag(pluginId, category, tags, query, owner, canSeeHidden, currentUserId) - (select ++ fr"ORDER BY" ++ ordering ++ fr"LIMIT $limit OFFSET $offset").query[APIV2QueryProject].map(_.asProtocol) - } - - def projectCountQuery( - pluginId: Option[String], - category: List[Category], - tags: List[(String, Option[String])], - query: Option[String], - owner: Option[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Query0[Long] = { - val select = projectSelectFrag(pluginId, category, tags, query, owner, canSeeHidden, currentUserId) - (sql"SELECT COUNT(*) FROM " ++ Fragments.parentheses(select) ++ fr"sq").query[Long] - } - - def projectMembers(pluginId: String, limit: Long, offset: Long): Query0[APIV2.ProjectMember] = - sql"""|SELECT u.name, array_agg(r.name) - | FROM projects p - | JOIN user_project_roles upr ON p.id = upr.project_id - | JOIN users u ON upr.user_id = u.id - | JOIN roles r ON upr.role_type = r.name - | WHERE p.plugin_id = $pluginId - | GROUP BY u.name ORDER BY max(r.permission::BIGINT) DESC LIMIT $limit OFFSET $offset""".stripMargin - .query[APIV2QueryProjectMember] - .map(_.asProtocol) - - def versionSelectFrag( - pluginId: String, - versionName: Option[String], - tags: List[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Fragment = { - val base = - sql"""|SELECT pv.created_at, - | pv.version_string, - | pv.dependencies, - | pv.visibility, - | pv.description, - | coalesce((SELECT sum(pvd.downloads) FROM project_versions_downloads pvd WHERE p.id = pvd.project_id AND pv.id = pvd.version_id), 0), - | pv.file_size, - | pv.hash, - | pv.file_name, - | u.name, - | pv.review_state, - | array_append(array_agg(pvt.name ORDER BY (pvt.name)) FILTER ( WHERE pvt.name IS NOT NULL ), 'Channel') AS tag_names, - | array_append(array_agg(pvt.data ORDER BY (pvt.name)) FILTER ( WHERE pvt.name IS NOT NULL ), pc.name) AS tag_datas, - | array_append(array_agg(pvt.color ORDER BY (pvt.name)) FILTER ( WHERE pvt.name IS NOT NULL ), pc.color + 9) AS tag_colors - | FROM projects p - | JOIN project_versions pv ON p.id = pv.project_id - | LEFT JOIN users u ON pv.author_id = u.id - | LEFT JOIN project_version_tags pvt ON pv.id = pvt.version_id - | LEFT JOIN project_channels pc ON pv.channel_id = pc.id """.stripMargin - - val visibilityFrag = - if (canSeeHidden) None - else - currentUserId.fold(Some(fr"(pv.visibility = 1)")) { id => - Some( - fr"(pv.visibility = 1 OR ($id IN (SELECT pm.user_id FROM project_members_all pm WHERE pm.id = p.id) AND pv.visibility != 5))" - ) - } - - val filters = Fragments.whereAndOpt( - Some(fr"p.plugin_id = $pluginId"), - versionName.map(v => fr"pv.version_string = $v"), - NonEmptyList - .fromList(tags) - .map { t => - Fragments.or( - Fragments.in(fr"pvt.name || ':' || pvt.data", t), - Fragments.in(fr"pvt.name", t), - Fragments.in(fr"'Channel:' || pc.name", t), - Fragments.in(fr"'Channel'", t) - ) - }, - visibilityFrag - ) - - base ++ filters ++ fr"GROUP BY p.id, pv.id, u.id, pc.id" - } - - def versionQuery( - pluginId: String, - versionName: Option[String], - tags: List[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]], - limit: Long, - offset: Long - ): Query0[APIV2.Version] = - (versionSelectFrag(pluginId, versionName, tags, canSeeHidden, currentUserId) ++ fr"ORDER BY pv.created_at DESC LIMIT $limit OFFSET $offset") - .query[APIV2QueryVersion] - .map(_.asProtocol) - - def versionCountQuery( - pluginId: String, - tags: List[String], - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Query0[Long] = - (sql"SELECT COUNT(*) FROM " ++ Fragments.parentheses( - versionSelectFrag(pluginId, None, tags, canSeeHidden, currentUserId) - ) ++ fr"sq").query[Long] - - def userQuery(name: String): Query0[APIV2.User] = - sql"""|SELECT u.created_at, u.name, u.tagline, u.join_date, array_agg(r.name) - | FROM users u - | JOIN user_global_roles ugr ON u.id = ugr.user_id - | JOIN roles r ON ugr.role_id = r.id - | WHERE u.name = $name - | GROUP BY u.id""".stripMargin.query[APIV2QueryUser].map(_.asProtocol) - - private def actionFrag( - table: Fragment, - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Fragment = { - val base = - sql"""|SELECT p.plugin_id, - | p.name, - | p.owner_name, - | p.slug, - | p.promoted_versions, - | p.views, - | p.downloads, - | p.recent_views, - | p.recent_downloads, - | p.stars, - | p.watchers, - | p.category, - | p.visibility - | FROM users u JOIN """.stripMargin ++ table ++ - fr"""|ps ON u.id = ps.user_id - | JOIN home_projects p ON ps.project_id = p.id""".stripMargin - - val visibilityFrag = - if (canSeeHidden) None - else - currentUserId.fold(Some(fr"(p.visibility = 1 OR p.visibility = 2)")) { id => - Some(fr"(p.visibility = 1 OR p.visibility = 2 OR ($id = ANY(p.project_members) AND p.visibility != 5))") - } - - val filters = Fragments.whereAndOpt( - Some(fr"u.name = $user"), - visibilityFrag - ) - - base ++ filters - } - - private def actionQuery( - table: Fragment, - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]], - order: ProjectSortingStrategy, - limit: Long, - offset: Long - ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = { - val ordering = order.fragment - - val select = actionFrag(table, user, canSeeHidden, currentUserId) - (select ++ fr"ORDER BY" ++ ordering ++ fr"LIMIT $limit OFFSET $offset") - .query[APIV2QueryCompactProject] - .map(_.asProtocol) - } - - private def actionCountQuery( - table: Fragment, - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Query0[Long] = { - val select = actionFrag(table, user, canSeeHidden, currentUserId) - (sql"SELECT COUNT(*) FROM " ++ Fragments.parentheses(select) ++ fr"sq").query[Long] - } - - def starredQuery( - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]], - order: ProjectSortingStrategy, - limit: Long, - offset: Long - ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = - actionQuery(Fragment.const("project_stars"), user, canSeeHidden, currentUserId, order, limit, offset) - - def starredCountQuery( - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Query0[Long] = actionCountQuery(Fragment.const("project_stars"), user, canSeeHidden, currentUserId) - - def watchingQuery( - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]], - order: ProjectSortingStrategy, - limit: Long, - offset: Long - ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = - actionQuery(Fragment.const("project_watchers"), user, canSeeHidden, currentUserId, order, limit, offset) - - def watchingCountQuery( - user: String, - canSeeHidden: Boolean, - currentUserId: Option[DbRef[User]] - ): Query0[Long] = actionCountQuery(Fragment.const("project_watchers"), user, canSeeHidden, currentUserId) - - def projectStats(pluginId: String, startDate: LocalDate, endDate: LocalDate): Query0[APIV2ProjectStatsQuery] = - sql"""|SELECT CAST(dates.day as DATE), coalesce(sum(pvd.downloads), 0) AS downloads, coalesce(pv.views, 0) AS views - | FROM projects p, - | (SELECT generate_series($startDate::DATE, $endDate::DATE, INTERVAL '1 DAY') AS day) dates - | LEFT JOIN project_versions_downloads pvd ON dates.day = pvd.day - | LEFT JOIN project_views pv ON dates.day = pv.day AND pvd.project_id = pv.project_id - | WHERE p.plugin_id = $pluginId - | AND (pvd IS NULL OR pvd.project_id = p.id) - | GROUP BY pv.views, dates.day;""".stripMargin.query[APIV2ProjectStatsQuery] - - def versionStats( - pluginId: String, - versionString: String, - startDate: LocalDate, - endDate: LocalDate - ): Query0[APIV2VersionStatsQuery] = - sql"""|SELECT CAST(dates.day as DATE), coalesce(pvd.downloads, 0) AS downloads - | FROM projects p, - | project_versions pv, - | (SELECT generate_series($startDate::DATE, $endDate::DATE, INTERVAL '1 DAY') AS day) dates - | LEFT JOIN project_versions_downloads pvd ON dates.day = pvd.day - | WHERE p.plugin_id = $pluginId - | AND pv.version_string = $versionString - | AND (pvd IS NULL OR (pvd.project_id = p.id AND pvd.version_id = pv.id));""".stripMargin - .query[APIV2VersionStatsQuery] -} diff --git a/apiV2/app/db/impl/query/apiv2/APIV2Queries.scala b/apiV2/app/db/impl/query/apiv2/APIV2Queries.scala new file mode 100644 index 000000000..4a8a6141b --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/APIV2Queries.scala @@ -0,0 +1,76 @@ +package db.impl.query.apiv2 + +import models.protocols.APIV2 +import models.querymodels._ +import ore.db.DbRef +import ore.db.impl.query.DoobieOreProtocol +import ore.models.user.User + +import cats.Reducible +import cats.syntax.all._ +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta +import doobie.util.Put +import doobie.util.fragment.Elem +import perspective._ +import perspective.syntax.all._ + +trait APIV2Queries extends DoobieOreProtocol { + + //Like in, but takes a tuple + def in2[F[_]: Reducible, A: Put, B: Put](f: Fragment, fs: F[(A, B)]): Fragment = + fs.toList.map { case (a, b) => fr0"($a, $b)" }.foldSmash1(f ++ fr0"IN (", fr",", fr")") + + def array[F[_]: Reducible, A: Put](fs: F[A]): Fragment = + fs.toList.map(a => fr0"$a").foldSmash1(fr0"ARRAY[", fr",", fr0"]") + + def array2Text[F[_]: Reducible, A: Put, B: Put](t1: String, t2: String)(fs: F[(A, B)]): Fragment = + fs.toList + .map { case (a, b) => fr0"($a::" ++ Fragment.const(t1) ++ fr0", $b::" ++ Fragment.const(t2) ++ fr0")::TEXT" } + .foldSmash1(fr0"ARRAY[", fr",", fr0"]") + + case class Column[A](name: String, mkElem: A => Elem) + object Column { + def arg[A](name: String)(implicit put: Put[A]): Column[A] = Column(name, Elem.Arg(_, put)) + def opt[A](name: String)(implicit put: Put[A]): Column[Option[A]] = Column(name, Elem.Opt(_, put)) + } + + protected def updateTable[F[_[_]]: ApplicativeKC: FoldableKC]( + table: String, + columns: F[Column], + edits: F[Option] + ): Fragment = { + + val applyUpdate = new FunctionK[Tuple2K[Option, Column, *], Compose2[Option, Const[Fragment, *], *]] { + override def apply[A](tuple: Tuple2K[Option, Column, A]): Option[Fragment] = { + val column = tuple._2 + tuple._1.map(value => Fragment.const(column.name) ++ Fragment("= ?", List(column.mkElem(value)))) + } + } + + val updatesSeq = edits + .map2KC(columns)(applyUpdate) + .foldMapKC[List[Option[Fragment]]]( + λ[Compose2[Option, Const[Fragment, *], *] ~>: Compose3[List, Option, Const[Fragment, *], *]](List(_)) + ) + + val updates = Fragments.setOpt(updatesSeq: _*) + + sql"""UPDATE """ ++ Fragment.const(table) ++ updates + } + + def countOfSelect(select: Fragment): doobie.Query0[Long] = + (sql"SELECT COUNT(*) FROM " ++ Fragments.parentheses(select) ++ fr"sq").query[Long] + + def visibilityFrag(canSeeHidden: Boolean, currentUserId: Option[DbRef[User]], table: Fragment): Option[Fragment] = { + Option.when(!canSeeHidden) { + currentUserId.fold(fr"($table.visibility = 1 OR $table.visibility = 2)") { id => + fr"($table.visibility = 1 OR $table.visibility = 2 OR ($id IN (SELECT pm.user_id FROM project_members_all pm WHERE pm.id = p.id) AND $table.visibility != 5))" + } + } + } +} diff --git a/apiV2/app/db/impl/query/apiv2/ActionsAndStatsQueries.scala b/apiV2/app/db/impl/query/apiv2/ActionsAndStatsQueries.scala new file mode 100644 index 000000000..e478df23d --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/ActionsAndStatsQueries.scala @@ -0,0 +1,148 @@ +package db.impl.query.apiv2 + +import java.time.LocalDate + +import models.protocols.APIV2 +import models.querymodels.{APIV2ProjectStatsQuery, APIV2QueryCompactProject, APIV2VersionStatsQuery} +import ore.db.DbRef +import ore.models.project.{Project, ProjectSortingStrategy} +import ore.models.user.User + +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta +import io.circe.DecodingFailure + +object ActionsAndStatsQueries extends APIV2Queries { + + private def actionFrag( + table: Fragment, + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + ): Fragment = { + val base = + sql"""|SELECT p.plugin_id, + | p.name, + | p.owner_name, + | p.slug, + | to_jsonb( + | ARRAY(SELECT jsonb_build_object( + | 'version_string', promoted.version_string, + | 'platforms', promoted.platforms, + | 'platform_versions', promoted.platform_versions, + | 'platform_coarse_versions', promoted.platform_coarse_versions, + | 'stability', promoted.stability, + | 'release_type', promoted.release_type) + | FROM promoted_versions promoted + | WHERE promoted.project_id = p.id + | ORDER BY promoted.platform_coarse_versions DESC LIMIT 5)) AS promoted_versions, + | ps.views, + | ps.downloads, + | ps.recent_views, + | ps.recent_downloads, + | ps.stars, + | ps.watchers, + | p.category, + | p.visibility + | FROM users u JOIN $table psw ON u.id = psw.user_id + | JOIN projects p ON psw.project_id = p.id JOIN project_stats ps ON psw.project_id = ps.id """.stripMargin + + val filters = Fragments.whereAndOpt( + Some(fr"u.name = $user"), + visibilityFrag(canSeeHidden, currentUserId, fr0"p") + ) + + base ++ filters + } + + private def actionQuery( + table: Fragment, + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + order: ProjectSortingStrategy, + limit: Long, + offset: Long + ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = { + val ordering = order.fragment + + val select = actionFrag(table, user, canSeeHidden, currentUserId) + (select ++ fr"ORDER BY" ++ ordering ++ fr"LIMIT $limit OFFSET $offset") + .query[APIV2QueryCompactProject] + .map(_.asProtocol) + } + + private def actionCountQuery( + table: Fragment, + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + ): Query0[Long] = + countOfSelect(actionFrag(table, user, canSeeHidden, currentUserId)) + + def starredQuery( + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + order: ProjectSortingStrategy, + limit: Long, + offset: Long + ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = + actionQuery(Fragment.const("project_stars"), user, canSeeHidden, currentUserId, order, limit, offset) + + def starredCountQuery( + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + ): Query0[Long] = actionCountQuery(Fragment.const("project_stars"), user, canSeeHidden, currentUserId) + + def watchingQuery( + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + order: ProjectSortingStrategy, + limit: Long, + offset: Long + ): Query0[Either[DecodingFailure, APIV2.CompactProject]] = + actionQuery(Fragment.const("project_watchers"), user, canSeeHidden, currentUserId, order, limit, offset) + + def watchingCountQuery( + user: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + ): Query0[Long] = actionCountQuery(Fragment.const("project_watchers"), user, canSeeHidden, currentUserId) + + def projectStats( + projectId: DbRef[Project], + startDate: LocalDate, + endDate: LocalDate + ): Query0[APIV2ProjectStatsQuery] = + sql"""|SELECT CAST(dates.day AS DATE), coalesce(sum(pvd.downloads), 0) AS downloads, coalesce(pv.views, 0) AS views + | FROM projects p, + | (SELECT generate_series($startDate::DATE, $endDate::DATE, INTERVAL '1 DAY') AS day) dates + | LEFT JOIN project_versions_downloads pvd ON dates.day = pvd.day + | LEFT JOIN project_views pv ON dates.day = pv.day AND pvd.project_id = pv.project_id + | WHERE p.id = $projectId + | AND (pvd IS NULL OR pvd.project_id = p.id) + | GROUP BY pv.views, dates.day;""".stripMargin.query[APIV2ProjectStatsQuery] + + def versionStats( + projectId: DbRef[Project], + versionString: String, + startDate: LocalDate, + endDate: LocalDate + ): Query0[APIV2VersionStatsQuery] = + sql"""|SELECT CAST(dates.day AS DATE), coalesce(pvd.downloads, 0) AS downloads + | FROM projects p, + | project_versions pv, + | (SELECT generate_series($startDate::DATE, $endDate::DATE, INTERVAL '1 DAY') AS day) dates + | LEFT JOIN project_versions_downloads pvd ON dates.day = pvd.day + | WHERE p.id = $projectId + | AND pv.version_string = $versionString + | AND (pvd IS NULL OR (pvd.project_id = p.id AND pvd.version_id = pv.id));""".stripMargin + .query[APIV2VersionStatsQuery] +} diff --git a/apiV2/app/db/impl/query/apiv2/AuthQueries.scala b/apiV2/app/db/impl/query/apiv2/AuthQueries.scala new file mode 100644 index 000000000..3326c0e85 --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/AuthQueries.scala @@ -0,0 +1,66 @@ +package db.impl.query.apiv2 + +import controllers.sugar.Requests.ApiAuthInfo +import ore.db.DbRef +import ore.models.api.ApiKey +import ore.models.user.User +import ore.permission.Permission + +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta + +object AuthQueries extends APIV2Queries { + + def getApiAuthInfo(token: String): Query0[ApiAuthInfo] = { + sql"""|SELECT u.id, + | u.created_at, + | u.full_name, + | u.name, + | u.email, + | u.tagline, + | u.join_date, + | u.read_prompts, + | u.language, + | ak.name, + | ak.owner_id, + | ak.token, + | ak.raw_key_permissions, + | aks.token, + | aks.expires, + | CASE + | WHEN u.id IS NULL THEN 1::BIT(64) + | ELSE (coalesce(gt.permission, B'0'::BIT(64)) | ( + | -- Default permissions if no global trust is found + | 1::BIT(64) | -- ViewPublicInfo + | (1::BIT(64) << 1) | -- Permission.EditOwnUserSettings + | (1::BIT(64) << 2) -- Permission.EditApiKeys + | )) & coalesce(ak.raw_key_permissions, (-1)::BIT(64)) + | END + | FROM api_sessions aks + | LEFT JOIN api_keys ak ON aks.key_id = ak.id + | LEFT JOIN users u ON aks.user_id = u.id + | LEFT JOIN global_trust gt ON gt.user_id = u.id + | WHERE aks.token = $token""".stripMargin.query[ApiAuthInfo] + } + + def findApiKey(identifier: String, token: String): Query0[(DbRef[ApiKey], DbRef[User])] = + sql"""SELECT k.id, k.owner_id FROM api_keys k WHERE k.token_identifier = $identifier AND k.token = crypt($token, k.token)""" + .query[(DbRef[ApiKey], DbRef[User])] + + def createApiKey( + name: String, + ownerId: DbRef[User], + tokenIdentifier: String, + token: String, + perms: Permission + ): doobie.Update0 = + sql"""|INSERT INTO api_keys (created_at, name, owner_id, token_identifier, token, raw_key_permissions) + |VALUES (now(), $name, $ownerId, $tokenIdentifier, crypt($token, gen_salt('bf')), $perms)""".stripMargin.update + + def deleteApiKey(name: String, ownerId: DbRef[User]): doobie.Update0 = + sql"""DELETE FROM api_keys k WHERE k.name = $name AND k.owner_id = $ownerId""".update +} diff --git a/apiV2/app/db/impl/query/apiv2/OrganizationQueries.scala b/apiV2/app/db/impl/query/apiv2/OrganizationQueries.scala new file mode 100644 index 000000000..14ae266e6 --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/OrganizationQueries.scala @@ -0,0 +1,45 @@ +package db.impl.query.apiv2 + +import models.protocols.APIV2 +import models.querymodels.APIV2QueryOrganization +import ore.db.DbRef +import ore.models.user.User + +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta + +object OrganizationQueries extends APIV2Queries { + + def organizationQuery(name: String): Query0[APIV2.Organization] = + sql"""|SELECT ou.name, + | u.created_at, + | u.name, + | u.tagline, + | u.join_date, + | count(DISTINCT p.plugin_id), + | array_remove(array_agg(DISTINCT r.name), NULL) + | FROM organizations o + | JOIN users u ON o.user_id = u.id -- Org user + | JOIN users ou ON o.owner_id = ou.id -- Org owner + | LEFT JOIN user_global_roles ugr ON u.id = ugr.user_id + | LEFT JOIN roles r ON ugr.role_id = r.id + | LEFT JOIN project_members_all pma ON u.id = pma.user_id + | LEFT JOIN projects p ON p.id = pma.id + | WHERE u.name = $name + | GROUP BY ou.id, u.id""".stripMargin.query[APIV2QueryOrganization].map(_.asProtocol) + + def canUploadToOrg(uploader: DbRef[User], orgName: String): Query0[(DbRef[User], Boolean)] = + sql"""|SELECT ou.id, + | ((coalesce(gt.permission, B'0'::BIT(64)) | coalesce(ot.permission, B'0'::BIT(64))) & + | (1::BIT(64) << 12)) = (1::BIT(64) << 12) -- Permission.CreateVersion + | FROM organizations o + | JOIN users ou ON o.user_id = ou.id + | LEFT JOIN user_organization_roles om ON o.id = om.organization_id AND om.user_id = $uploader + | LEFT JOIN global_trust gt ON gt.user_id = om.user_id + | LEFT JOIN organization_trust ot ON ot.user_id = om.user_id AND ot.organization_id = o.id + | WHERE o.name = $orgName""".stripMargin.query[(DbRef[User], Boolean)] +} diff --git a/apiV2/app/db/impl/query/apiv2/PageQueries.scala b/apiV2/app/db/impl/query/apiv2/PageQueries.scala new file mode 100644 index 000000000..6a70afd50 --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/PageQueries.scala @@ -0,0 +1,72 @@ +package db.impl.query.apiv2 + +import controllers.apiv2.Pages +import ore.db.DbRef +import ore.models.project.{Page, Project} + +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta + +object PageQueries extends APIV2Queries { + + def getPage( + projectId: DbRef[Project], + page: String + ): Query0[(DbRef[Page], String, Option[String])] = + sql"""|WITH RECURSIVE pages_rec(n, name, slug, contents, id) AS ( + | SELECT 2, pp.name, pp.slug, pp.contents, pp.id + | FROM project_pages pp + | WHERE pp.project_id = $projectId + | AND lower(split_part($page, '/', 1)) = lower(pp.slug) + | AND pp.parent_id IS NULL + | UNION + | SELECT pr.n + 1, pp.name, pp.slug, pp.contents, pp.id + | FROM pages_rec pr, + | project_pages pp + | WHERE pp.project_id = $projectId + | AND pp.parent_id = pr.id + | AND lower(split_part($page, '/', pr.n)) = lower(pp.slug) + |) + |SELECT pp.id, pp.name, pp.contents + | FROM pages_rec pp + | WHERE lower(pp.slug) = lower(split_part($page, '/', array_length(regexp_split_to_array($page, '/'), 1)));""".stripMargin + .query[(DbRef[Page], String, Option[String])] + + def pageList( + projectId: DbRef[Project] + ): Query0[(DbRef[Page], List[String], List[String], Boolean)] = + sql"""|WITH RECURSIVE pages_rec(name, slug, id, navigational) AS ( + | SELECT ARRAY[pp.name]::TEXT[], ARRAY[pp.slug]::TEXT[], pp.id, pp.contents IS NULL + | FROM project_pages pp + | WHERE pp.project_id = $projectId + | AND pp.parent_id IS NULL + | UNION + | SELECT array_append(pr.name, pp.name::TEXT), array_append(pr.slug, pp.slug::TEXT), pp.id, pp.contents IS NULL + | FROM pages_rec pr, + | project_pages pp + | WHERE pp.project_id = $projectId + | AND pp.parent_id = pr.id + |) + |SELECT pp.id, pp.name, pp.slug, navigational + | FROM pages_rec pp ORDER BY pp.name;""".stripMargin + .query[(DbRef[Page], List[String], List[String], Boolean)] + + def patchPage( + patch: Pages.PatchPageF[Option], + newSlug: Option[String], + id: DbRef[Page], + parentId: Option[Option[DbRef[Page]]] + ): doobie.Update0 = { + val sets = Fragments.setOpt( + patch.name.map(n => fr"name = $n"), + newSlug.map(n => fr"slug = $n"), + patch.content.map(c => fr"contents = $c"), + parentId.map(p => fr"parent_id = $p") + ) + (sql"UPDATE project_pages " ++ sets ++ fr"WHERE id = $id").update + } +} diff --git a/apiV2/app/db/impl/query/apiv2/ProjectQueries.scala b/apiV2/app/db/impl/query/apiv2/ProjectQueries.scala new file mode 100644 index 000000000..9ee81da87 --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/ProjectQueries.scala @@ -0,0 +1,269 @@ +package db.impl.query.apiv2 + +import play.api.mvc.RequestHeader + +import controllers.apiv2.Projects +import models.protocols.APIV2 +import models.querymodels.APIV2QueryProject +import ore.{OreConfig, OrePlatform} +import ore.data.project.Category +import ore.db.DbRef +import ore.models.project.{Project, ProjectSortingStrategy, Version} +import ore.models.project.io.ProjectFiles +import ore.models.user.User + +import cats.data.NonEmptyList +import cats.syntax.all._ +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta +import zio.ZIO +import zio.blocking.Blocking + +object ProjectQueries extends APIV2Queries { + + def projectSelectFrag( + projectSlug: Option[String], + category: List[Category], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + query: Option[String], + owner: Option[String], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + exactSearch: Boolean + )(implicit config: OreConfig): Fragment = { + val userActionsTaken = currentUserId.fold(fr"FALSE, FALSE,") { id => + fr"""|EXISTS(SELECT * FROM project_stars s WHERE s.project_id = p.id AND s.user_id = $id) AS user_stared, + |EXISTS(SELECT * FROM project_watchers S WHERE S.project_id = p.id AND S.user_id = $id) AS user_watching,""".stripMargin + } + + val base = + sql"""|SELECT p.created_at, + | p.plugin_id, + | p.name, + | p.owner_name, + | p.slug, + | to_jsonb( + | ARRAY(SELECT jsonb_build_object( + | 'version_string', promoted.version_string, + | 'platforms', promoted.platforms, + | 'platform_versions', promoted.platform_versions, + | 'platform_coarse_versions', promoted.platform_coarse_versions, + | 'stability', promoted.stability, + | 'release_type', promoted.release_type) + | FROM promoted_versions promoted + | WHERE promoted.project_id = p.id + | ORDER BY promoted.platform_coarse_versions DESC LIMIT 5)) AS promoted_versions, + | ps.views, + | ps.downloads, + | ps.recent_views, + | ps.recent_downloads, + | ps.stars, + | ps.watchers, + | p.category, + | p.description, + | ps.last_updated, + | p.visibility, + | p.topic_id, + | p.post_id, + | $userActionsTaken + | p.keywords, + | p.homepage, + | p.issues, + | p.source, + | p.support, + | p.license_name, + | p.license_url, + | p.forum_sync + | FROM projects p JOIN project_stats ps ON p.id = ps.id """.stripMargin + + val (platformsWithVersion, platformsWithoutVersion) = platforms.partitionEither { + case (name, Some(version)) => + Left((name, config.ore.platformsByName.get(name).fold(version)(OrePlatform.coarseVersionOf(_)(version)))) + case (name, None) => Right(name) + } + + val filters = Fragments.whereAndOpt( + projectSlug.map(slug => fr"lower(p.slug) = lower($slug)"), + NonEmptyList.fromList(category).map(Fragments.in(fr"p.category", _)), + if (platforms.nonEmpty || stability.nonEmpty) { + val jsSelect = + sql"""|SELECT promoted.platform + | FROM (SELECT unnest(ppv.platforms) AS platform, + | unnest(ppv.platform_coarse_versions) AS platform_coarse_version, + | ppv.stability + | FROM promoted_versions ppv WHERE ppv.project_id = p.id) AS promoted """.stripMargin ++ + Fragments.whereAndOpt( + NonEmptyList + .fromList(platformsWithVersion) + .map(t => in2(fr"(promoted.platform, promoted.platform_coarse_version)", t)), + NonEmptyList.fromList(platformsWithoutVersion).map(t => Fragments.in(fr"promoted.platform", t)), + NonEmptyList.fromList(stability).map(Fragments.in(fr"promoted.stability", _)) + ) + + Some(fr"EXISTS" ++ Fragments.parentheses(jsSelect)) + } else + None, + query.map { q => + val trimmedQ = q.trim + + if (exactSearch) { + fr"lower(p.slug) = lower($trimmedQ)" + } else { + if (q.endsWith(" ")) fr"p.search_words @@ websearch_to_tsquery('english', $trimmedQ)" + else fr"p.search_words @@ websearch_to_tsquery_postfix('english', $trimmedQ)" + } + }, + owner.map(o => fr"p.owner_name = $o"), + visibilityFrag(canSeeHidden, currentUserId, fr0"p") + ) + + val groupBy = + fr"GROUP BY p.id, ps.views, ps.downloads, ps.recent_views, ps.recent_downloads, ps.stars, ps.watchers, ps.last_updated" + + base ++ filters ++ groupBy + } + + def projectQuery( + projectSlug: Option[String], + category: List[Category], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + query: Option[String], + owner: Option[String], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + order: ProjectSortingStrategy, + orderWithRelevance: Boolean, + exactSearch: Boolean, + limit: Long, + offset: Long + )( + implicit projectFiles: ProjectFiles[ZIO[Blocking, Nothing, *]], + requestHeader: RequestHeader, + config: OreConfig + ): Query0[ZIO[Blocking, Nothing, APIV2.Project]] = { + val ordering = if (orderWithRelevance && query.nonEmpty) { + val relevance = query.fold(fr"1") { q => + val trimmedQ = q.trim + + if (q.endsWith(" ")) fr"ts_rank(p.search_words, websearch_to_tsquery('english', $trimmedQ)) DESC" + else fr"ts_rank(p.search_words, websearch_to_tsquery_postfix('english', $trimmedQ)) DESC" + } + + // 1483056000 is the Ore epoch + // 86400 seconds to days + // 604800 seconds to weeks + order match { + case ProjectSortingStrategy.MostStars => fr"ps.stars *" ++ relevance + case ProjectSortingStrategy.MostDownloads => fr"(ps.downloads / 100) *" ++ relevance + case ProjectSortingStrategy.MostViews => fr"(ps.views / 200) *" ++ relevance + case ProjectSortingStrategy.Newest => + fr"((EXTRACT(EPOCH FROM p.created_at) - 1483056000) / 86400) *" ++ relevance + case ProjectSortingStrategy.RecentlyUpdated => + fr"((EXTRACT(EPOCH FROM ps.last_updated) - 1483056000) / 604800) *" ++ relevance + case ProjectSortingStrategy.OnlyRelevance => relevance + case ProjectSortingStrategy.RecentViews => fr"ps.recent_views *" ++ relevance + case ProjectSortingStrategy.RecentDownloads => fr"ps.recent_downloads*" ++ relevance + } + } else order.fragment + + val select = projectSelectFrag( + projectSlug, + category, + platforms, + stability, + query, + owner, + canSeeHidden, + currentUserId, + exactSearch + ) + (select ++ fr"ORDER BY" ++ ordering ++ fr"LIMIT $limit OFFSET $offset").query[APIV2QueryProject].map(_.asProtocol) + } + + def singleProjectQuery( + projectOwner: String, + projectSlug: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + )( + implicit projectFiles: ProjectFiles[ZIO[Blocking, Nothing, *]], + requestHeader: RequestHeader, + config: OreConfig + ): Query0[ZIO[Blocking, Nothing, APIV2.Project]] = + projectQuery( + Some(projectSlug), + Nil, + Nil, + Nil, + None, + Some(projectOwner), + canSeeHidden, + currentUserId, + ProjectSortingStrategy.Default, + orderWithRelevance = false, + exactSearch = false, + 1, + 0 + ) + + def projectCountQuery( + projectSlug: Option[String], + category: List[Category], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + query: Option[String], + owner: Option[String], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + exactSearch: Boolean + )(implicit config: OreConfig): Query0[Long] = + countOfSelect( + projectSelectFrag( + projectSlug, + category, + platforms, + stability, + query, + owner, + canSeeHidden, + currentUserId, + exactSearch + ) + ) + + def updateProject(projectId: DbRef[Project], edits: Projects.EditableProject): Update0 = { + val projectColumns = Projects.EditableProjectF[Column]( + Column.arg("name"), + Projects.EditableProjectNamespaceF[Column](Column.arg("owner_name")), + Column.arg("category"), + Column.opt("description"), + Projects.EditableProjectSettingsF[Column]( + Column.arg("keywords"), + Column.opt("homepage"), + Column.opt("issues"), + Column.opt("source"), + Column.opt("support"), + Projects.EditableProjectLicenseF[Column]( + Column.opt("license_name"), + Column.opt("license_url") + ), + Column.arg("forum_sync") + ) + ) + + import cats.instances.option._ + import cats.instances.tuple._ + + val (newOwnerSet, newOwnerFrom, newOwnerFilter) = edits.namespace.owner.foldMap { owner => + (fr", owner_id = u.id", fr"FROM users u", fr"AND u.name = $owner") + } + + (updateTable("projects p", projectColumns, edits) ++ newOwnerSet ++ newOwnerFrom ++ fr" WHERE p.id = $projectId " ++ newOwnerFilter).update + } +} diff --git a/apiV2/app/db/impl/query/apiv2/UserQueries.scala b/apiV2/app/db/impl/query/apiv2/UserQueries.scala new file mode 100644 index 000000000..7b25d033e --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/UserQueries.scala @@ -0,0 +1,150 @@ +package db.impl.query.apiv2 + +import controllers.apiv2.Users +import controllers.apiv2.Users.UserSortingStrategy +import models.protocols.APIV2 +import models.querymodels.{APIV2QueryMember, APIV2QueryMembership, APIV2QueryUser} +import ore.db.DbRef +import ore.models.organization.Organization +import ore.models.project.Project +import ore.permission.role.Role + +import cats.data.NonEmptyList +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta + +object UserQueries extends APIV2Queries { + + def userSearchFrag( + q: Option[String], + minProjects: Long, + roles: Seq[Role], + excludeOrganizations: Boolean + ): Fragment = { + val whereFilters = Fragments.whereAndOpt( + q.map(s => if (s.endsWith("%")) fr"u.name LIKE $s" else fr"u.name LIKE ${s + "%"}"), + Option.when(excludeOrganizations)(fr"r IS NULL OR r.name != 'Organization'"), + NonEmptyList.fromList(roles.toList).map(roles => Fragments.in(fr"r.name", roles)) + ) + val outerFilters = Fragments.andOpt( + Option.when(minProjects > 0)(fr"count(p.plugin_id) >= $minProjects") + ) + val havingFilters = if (outerFilters == Fragment.empty) Fragment.empty else fr"HAVING" ++ outerFilters + + sql"""|SELECT u.created_at, + | u.name, + | u.tagline, + | u.join_date, + | count(p.plugin_id) AS projects, + | array_remove(array_agg(DISTINCT r.name), NULL) AS roles + | FROM users u + | LEFT JOIN project_members_all pma ON u.id = pma.user_id + | LEFT JOIN projects p ON p.id = pma.id + | LEFT JOIN user_global_roles ugr ON u.id = ugr.user_id + | LEFT JOIN roles r ON ugr.role_id = r.ID + | $whereFilters + | GROUP BY u.ID + | $havingFilters""".stripMargin + } + + def userSearchQuery( + q: Option[String], + minProjects: Long, + roles: Seq[Role], + excludeOrganizations: Boolean, + strategy: Users.UserSortingStrategy, + sortDescending: Boolean, + limit: Long, + offset: Long + ): Query0[APIV2.User] = { + val select = userSearchFrag(q, minProjects, roles, excludeOrganizations) + + val primaryRawSort = strategy match { + case UserSortingStrategy.Name => fr"name" + case UserSortingStrategy.Joined => fr"created_at" + case UserSortingStrategy.Projects => fr"projects" + } + val primarySort = if (sortDescending) primaryRawSort ++ fr"DESC" else primaryRawSort ++ fr"ASC" + val sortFrag = if (strategy != UserSortingStrategy.Name) primarySort ++ fr", name" else primarySort + + (select ++ fr" ORDER BY" ++ sortFrag ++ fr"LIMIT $limit OFFSET $offset").query[APIV2QueryUser].map(_.asProtocol) + } + + def userSearchCountQuery( + q: Option[String], + minProjects: Long, + roles: Seq[Role], + excludeOrganizations: Boolean + ): Query0[Long] = + countOfSelect(userSearchFrag(q, minProjects, roles, excludeOrganizations)) + + def userQuery(name: String): Query0[APIV2.User] = + sql"""|SELECT u.created_at, + | u.name, + | u.tagline, + | u.join_date, + | count(DISTINCT p.plugin_id), + | array_remove(array_agg(DISTINCT r.name), NULL) + | FROM users u + | LEFT JOIN user_global_roles ugr ON u.id = ugr.user_id + | LEFT JOIN roles r ON ugr.role_id = r.id + | LEFT JOIN project_members_all pma ON u.id = pma.user_id + | LEFT JOIN projects p ON p.id = pma.id + | WHERE u.name = $name + | GROUP BY u.id""".stripMargin.query[APIV2QueryUser].map(_.asProtocol) + + def getMemberships(user: String): Query0[APIV2.Membership] = + sql"""|SELECT 'organization', o.name, NULL, NULL, NULL, uor.role_type, uor.is_accepted + | FROM user_organization_roles uor + | JOIN users u ON uor.user_id = u.id + | JOIN organizations o ON uor.organization_id = o.id + | WHERE u.name = $user + |UNION + |SELECT 'project', NULL, p.plugin_id, p.owner_name, p.slug, upr.role_type, upr.is_accepted + | FROM user_project_roles upr + | JOIN users u ON upr.user_id = u.id + | JOIN projects p ON upr.project_id = p.id + | WHERE u.name = $user""".stripMargin.query[APIV2QueryMembership].map(_.asProtocol) + + private def members( + subjectTable: Fragment, + roleTable: Fragment, + joinRolesOn: (Fragment, Fragment) => Fragment, + where: Fragment => Fragment, + limit: Long, + offset: Long + ): Query0[APIV2.Member] = + sql"""|SELECT u.name, r.name, usr.is_accepted + | FROM $subjectTable s + | JOIN $roleTable usr ON ${joinRolesOn(fr0"s", fr0"usr")} + | JOIN users u ON usr.user_id = u.id + | JOIN roles r ON usr.role_type = r.name + | WHERE ${where(fr0"s")} + | ORDER BY r.permission & ~B'1'::BIT(64) DESC LIMIT $limit OFFSET $offset""".stripMargin + .query[APIV2QueryMember] + .map(_.asProtocol) + + def projectMembers(projectId: DbRef[Project], limit: Long, offset: Long): Query0[APIV2.Member] = + members( + fr"projects", + fr"user_project_roles", + (p, upr) => fr"$p.id = $upr.project_id", + p => fr"$p.id = $projectId", + limit, + offset + ) + + def orgaMembers(organizationId: DbRef[Organization], limit: Long, offset: Long): Query0[APIV2.Member] = + members( + fr"organizations", + fr"user_organization_roles", + (o, opr) => fr"$o.id = $opr.organization_id", + o => fr"$o.id = $organizationId", + limit, + offset + ) +} diff --git a/apiV2/app/db/impl/query/apiv2/VersionQueries.scala b/apiV2/app/db/impl/query/apiv2/VersionQueries.scala new file mode 100644 index 000000000..5add6733e --- /dev/null +++ b/apiV2/app/db/impl/query/apiv2/VersionQueries.scala @@ -0,0 +1,137 @@ +package db.impl.query.apiv2 + +import controllers.apiv2.Versions +import models.protocols.APIV2 +import models.querymodels.APIV2QueryVersion +import ore.{OreConfig, OrePlatform} +import ore.db.DbRef +import ore.models.project.{Project, Version} +import ore.models.user.User + +import cats.data.NonEmptyList +import doobie._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.postgres.circe.jsonb.implicits._ +import doobie.implicits.javasql._ +import doobie.implicits.javatime.JavaTimeLocalDateMeta + +object VersionQueries extends APIV2Queries { + + def versionSelectFrag( + projectId: DbRef[Project], + versionName: Option[String], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + releaseType: List[Version.ReleaseType], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + )(implicit config: OreConfig): Fragment = { + val base = + sql"""|SELECT pv.created_at, + | pv.version_string, + | pv.dependency_ids, + | pv.dependency_versions, + | pv.visibility, + | coalesce((SELECT sum(pvd.downloads) FROM project_versions_downloads pvd WHERE p.id = pvd.project_id AND pv.id = pvd.version_id), 0), + | pv.file_size, + | pv.hash, + | pv.file_name, + | u.name, + | pv.review_state, + | pv.uses_mixin, + | pv.stability, + | pv.release_type, + | coalesce(array_agg(pvp.platform) FILTER ( WHERE pvp.platform IS NOT NULL ), ARRAY []::TEXT[]), + | coalesce(array_agg(pvp.platform_version) FILTER ( WHERE pvp.platform IS NOT NULL ), ARRAY []::TEXT[]), + | pv.post_id + | FROM projects p + | JOIN project_versions pv ON p.id = pv.project_id + | LEFT JOIN users u ON pv.author_id = u.id + | LEFT JOIN project_version_platforms pvp ON pv.id = pvp.version_id """.stripMargin + + val coarsePlatforms = platforms.map { + case (name, optVersion) => + ( + name, + optVersion.map(version => + config.ore.platformsByName.get(name).fold(version)(OrePlatform.coarseVersionOf(_)(version)) + ) + ) + } + + val filters = Fragments.whereAndOpt( + Some(fr"p.id = $projectId"), + versionName.map(v => fr"pv.version_string = $v"), + Option.when(coarsePlatforms.nonEmpty)( + Fragments.or( + coarsePlatforms.map { + case (platform, Some(version)) => fr"pvp.platform = $platform AND pvp.platform_coarse_version = $version" + case (platform, None) => fr"pvp.platform = $platform" + }: _* + ) + ), + NonEmptyList.fromList(stability).map(Fragments.in(fr"pv.stability", _)), + NonEmptyList.fromList(releaseType).map(Fragments.in(fr"pv.release_type", _)), + visibilityFrag(canSeeHidden, currentUserId, fr0"pv") + ) + + base ++ filters ++ fr"GROUP BY p.id, pv.id, u.id" + } + + def versionQuery( + projectId: DbRef[Project], + versionName: Option[String], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + releaseType: List[Version.ReleaseType], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]], + limit: Long, + offset: Long + )(implicit config: OreConfig): Query0[APIV2.Version] = + (versionSelectFrag( + projectId, + versionName, + platforms, + stability, + releaseType, + canSeeHidden, + currentUserId + ) ++ fr"ORDER BY pv.created_at DESC LIMIT $limit OFFSET $offset") + .query[APIV2QueryVersion] + .map(_.asProtocol) + + def singleVersionQuery( + projectId: DbRef[Project], + versionName: String, + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + )(implicit config: OreConfig): doobie.Query0[APIV2.Version] = + versionQuery(projectId, Some(versionName), Nil, Nil, Nil, canSeeHidden, currentUserId, 1, 0) + + def versionCountQuery( + projectId: DbRef[Project], + platforms: List[(String, Option[String])], + stability: List[Version.Stability], + releaseType: List[Version.ReleaseType], + canSeeHidden: Boolean, + currentUserId: Option[DbRef[User]] + )(implicit config: OreConfig): Query0[Long] = + countOfSelect( + versionSelectFrag(projectId, None, platforms, stability, releaseType, canSeeHidden, currentUserId) + ) + + def updateVersion( + projectId: DbRef[Project], + versionName: String, + edits: Versions.DbEditableVersion + ): Update0 = { + val versionColumns = Versions.DbEditableVersionF[Column]( + Column.arg("stability"), + Column.opt("release_type") + ) + + (updateTable("project_versions", versionColumns, edits) ++ fr" WHERE project_id = $projectId AND version_string = $versionName").update + } +} diff --git a/apiV2/app/models/protocols/APIV2.scala b/apiV2/app/models/protocols/APIV2.scala index 92fb381be..bc7580fa9 100644 --- a/apiV2/app/models/protocols/APIV2.scala +++ b/apiV2/app/models/protocols/APIV2.scala @@ -3,7 +3,9 @@ package models.protocols import java.time.OffsetDateTime import ore.data.project.Category +import ore.models.project.Version.{ReleaseType, Stability} import ore.models.project.{ReviewState, Visibility} +import ore.permission.NamedPermission import enumeratum._ import enumeratum.values._ @@ -34,6 +36,10 @@ object APIV2 { implicit val categoryCodec: Codec[Category] = valueEnumCodec(Category)(_.apiName) implicit val reviewStateCodec: Codec[ReviewState] = valueEnumCodec(ReviewState)(_.apiName) + implicit val namedPermissionCodec: Codec[NamedPermission] = APIV2.enumCodec(NamedPermission)(_.entryName) + + implicit val permissionRoleCodec: Codec[ore.permission.role.Role] = valueEnumCodec(ore.permission.role.Role)(_.value) + //Project @SnakeCaseJsonCodec case class Project( createdAt: OffsetDateTime, @@ -43,12 +49,22 @@ object APIV2 { promotedVersions: Seq[PromotedVersion], stats: ProjectStatsAll, category: Category, - description: Option[String], + summary: Option[String], lastUpdated: OffsetDateTime, visibility: Visibility, userActions: UserActions, settings: ProjectSettings, - iconUrl: String + iconUrl: String, + external: ProjectExternal + ) + + @SnakeCaseJsonCodec case class ProjectExternal( + discourse: ProjectExternalDiscourse + ) + + @SnakeCaseJsonCodec case class ProjectExternalDiscourse( + topicId: Option[Int], + postId: Option[Int] ) @SnakeCaseJsonCodec case class CompactProject( @@ -62,26 +78,18 @@ object APIV2 { ) @SnakeCaseJsonCodec case class ProjectNamespace(owner: String, slug: String) - @SnakeCaseJsonCodec case class PromotedVersion(version: String, tags: Seq[PromotedVersionTag]) - @SnakeCaseJsonCodec case class PromotedVersionTag( - name: String, - data: Option[String], - displayData: Option[String], - minecraftVersion: Option[String], - color: VersionTagColor - ) - @SnakeCaseJsonCodec case class VersionTag(name: String, data: Option[String], color: VersionTagColor) - @SnakeCaseJsonCodec case class VersionTagColor(foreground: String, background: String) + @SnakeCaseJsonCodec case class PromotedVersion(version: String, platforms: Seq[VersionPlatform]) @SnakeCaseJsonCodec case class ProjectStatsAll( views: Long, downloads: Long, - recent_views: Long, - recent_downloads: Long, + recentViews: Long, + recentDownloads: Long, stars: Long, watchers: Long ) @SnakeCaseJsonCodec case class UserActions(starred: Boolean, watching: Boolean) @SnakeCaseJsonCodec case class ProjectSettings( + keywords: Seq[String], homepage: Option[String], issues: Option[String], sources: Option[String], @@ -91,16 +99,34 @@ object APIV2 { ) @SnakeCaseJsonCodec case class ProjectLicense(name: Option[String], url: Option[String]) - //Project member - @SnakeCaseJsonCodec case class ProjectMember( + @SnakeCaseJsonCodec case class Member( user: String, - roles: List[Role] + role: Role + ) + + @SnakeCaseJsonCodec case class Membership( + scope: String, + organization: Option[MembershipOrganization], + project: Option[MembershipProject], + role: ore.permission.role.Role, + isAccepted: Boolean + ) + + @SnakeCaseJsonCodec case class MembershipOrganization( + name: String + ) + + @SnakeCaseJsonCodec case class MembershipProject( + pluginId: String, + namespace: ProjectNamespace ) @SnakeCaseJsonCodec case class Role( - name: String, + name: ore.permission.role.Role, title: String, - color: String + color: String, + permissions: List[NamedPermission], + isAccepted: Boolean ) //Version @@ -109,15 +135,39 @@ object APIV2 { name: String, dependencies: List[VersionDependency], visibility: Visibility, - description: Option[String], stats: VersionStatsAll, fileInfo: FileInfo, author: Option[String], reviewState: ReviewState, - tags: List[VersionTag] + tags: VersionTags, + external: VersionExternal ) - @SnakeCaseJsonCodec case class VersionDependency(plugin_id: String, version: Option[String]) + @SnakeCaseJsonCodec case class VersionExternal( + discourse: VersionExternalDiscourse + ) + + @SnakeCaseJsonCodec case class VersionExternalDiscourse( + postId: Option[Int] + ) + + @SnakeCaseJsonCodec case class VersionTags( + mixin: Boolean, + stability: Stability, + releaseType: Option[ReleaseType], + platforms: Seq[VersionPlatform] + ) + + @SnakeCaseJsonCodec case class VersionPlatform( + platform: String, + platformVersion: Option[String], + displayPlatformVersion: Option[String], + minecraftVersion: Option[String] + ) + + @SnakeCaseJsonCodec case class VersionChangelog(changelog: String) + + @SnakeCaseJsonCodec case class VersionDependency(pluginId: String, version: Option[String]) @SnakeCaseJsonCodec case class VersionStatsAll(downloads: Long) @SnakeCaseJsonCodec case class FileInfo(name: String, sizeBytes: Long, md5Hash: String) @@ -127,9 +177,15 @@ object APIV2 { name: String, tagline: Option[String], joinDate: Option[OffsetDateTime], + projectCount: Long, roles: List[Role] ) + @SnakeCaseJsonCodec case class Organization( + owner: String, + user: User + ) + @SnakeCaseJsonCodec case class ProjectStatsDay( downloads: Long, views: Long @@ -138,4 +194,19 @@ object APIV2 { @SnakeCaseJsonCodec case class VersionStatsDay( downloads: Long ) + + @SnakeCaseJsonCodec case class Page( + name: String, + content: Option[String] + ) + + @SnakeCaseJsonCodec case class PageList( + pages: Seq[PageListEntry] + ) + + @SnakeCaseJsonCodec case class PageListEntry( + name: Seq[String], + slug: Seq[String], + navigational: Boolean + ) } diff --git a/apiV2/app/models/querymodels/apiV2QueryModels.scala b/apiV2/app/models/querymodels/apiV2QueryModels.scala index e0be57e02..dd22465a5 100644 --- a/apiV2/app/models/querymodels/apiV2QueryModels.scala +++ b/apiV2/app/models/querymodels/apiV2QueryModels.scala @@ -11,8 +11,9 @@ import play.api.mvc.RequestHeader import models.protocols.APIV2 import ore.OreConfig import ore.data.project.{Category, ProjectNamespace} +import ore.models.project.Version.{ReleaseType, Stability} import ore.models.project.io.ProjectFiles -import ore.models.project.{ReviewState, TagColor, Visibility} +import ore.models.project.{ReviewState, Visibility} import ore.models.user.User import ore.permission.role.Role import util.syntax._ @@ -40,8 +41,11 @@ case class APIV2QueryProject( description: Option[String], lastUpdated: OffsetDateTime, visibility: Visibility, + topicId: Option[Int], + postId: Option[Int], userStarred: Boolean, userWatching: Boolean, + keywords: List[String], homepage: Option[String], issues: Option[String], sources: Option[String], @@ -92,6 +96,7 @@ case class APIV2QueryProject( userWatching ), APIV2.ProjectSettings( + keywords, homepage, issues, sources, @@ -99,7 +104,13 @@ case class APIV2QueryProject( APIV2.ProjectLicense(licenseName, licenseUrl), forumSync ), - iconUrl + iconUrl, + APIV2.ProjectExternal( + APIV2.ProjectExternalDiscourse( + topicId, + postId + ) + ) ) } } @@ -119,85 +130,77 @@ object APIV2QueryProject { val cursor = json.hcursor for { - version <- cursor.get[String]("version_string") - tagName <- cursor.get[String]("tag_name") - data <- cursor.get[Option[String]]("tag_version") - color <- cursor - .get[Int]("tag_color") - .flatMap { i => - TagColor - .withValueOpt(i) - .toRight(DecodingFailure(s"Invalid TagColor $i", cursor.downField("tag_color").history)) - } - } yield { + version <- cursor.get[String]("version_string") + platform <- cursor.get[List[String]]("platforms") + platformVersion <- cursor.get[List[Option[String]]]("platform_versions") + } yield APIV2.PromotedVersion( + version, + platform.zip(platformVersion).map { + case (platform, platformVersion) => decodeVersionPlatform(platform, platformVersion) + } + ) + } + } yield res - val displayAndMc = data.map { rawData => - lazy val lowerBoundVersion = for { - range <- Try(VersionRange.createFromVersionSpec(rawData)).toOption - version <- Option(range.getRecommendedVersion) - .orElse(range.getRestrictions.asScala.flatMap(r => Option(r.getLowerBound)).toVector.minimumOption) - } yield version + def decodeVersionPlatform(platform: String, platformVersion: Option[String]): APIV2.VersionPlatform = { + //TODO: Cleanup this in the DB so we don't need to deal with it + val displayAndMc = platformVersion.filterNot(_ == "null").map { rawData => + lazy val lowerBoundVersion = for { + range <- Try(VersionRange.createFromVersionSpec(rawData)).toOption + version <- Option(range.getRecommendedVersion) + .orElse(range.getRestrictions.asScala.flatMap(r => Option(r.getLowerBound)).toVector.minimumOption) + } yield version - lazy val lowerBoundVersionStr = lowerBoundVersion.map(_.toString) + lazy val lowerBoundVersionStr = lowerBoundVersion.map(_.toString) - def unzipOptions[A, B](fab: Option[(A, B)]): (Option[A], Option[B]) = fab match { - case Some((a, b)) => Some(a) -> Some(b) - case None => (None, None) - } + def unzipOptions[A, B](fab: Option[(A, B)]): (Option[A], Option[B]) = fab match { + case Some((a, b)) => Some(a) -> Some(b) + case None => (None, None) + } - tagName match { - case "Sponge" => - lowerBoundVersionStr.collect { - case MajorMinor(version) => version - } -> None //TODO - case "SpongeForge" => - unzipOptions( - lowerBoundVersionStr.collect { - case SpongeForgeMajorMinorMC(version, mcVersion) => version -> mcVersion - } - ) - case "SpongeVanilla" => - unzipOptions( - lowerBoundVersionStr.collect { - case SpongeVanillaMajorMinorMC(version, mcVersion) => version -> mcVersion - } - ) - case "Forge" => - lowerBoundVersion.flatMap { - //This will crash and burn if the implementation becomes - //something else, but better that, than failing silently - case version: DefaultArtifactVersion => - if (BigInt(version.version.getFirstInteger) >= 28) { - Some(version.toString) //Not sure what we really want to do here - } else { - version.toString match { - case OldForgeVersion(version) => Some(version) - case _ => None - } - } - } -> None //TODO - case _ => None -> None + //TODO: Move this to the platforms object + platform match { + case "spongeapi" => + lowerBoundVersionStr.collect { + case MajorMinor(version) => version + } -> None //TODO + case "spongeforge" => + unzipOptions( + lowerBoundVersionStr.collect { + case SpongeForgeMajorMinorMC(version, mcVersion) => version -> mcVersion + } + ) + case "spongevanilla" => + unzipOptions( + lowerBoundVersionStr.collect { + case SpongeVanillaMajorMinorMC(version, mcVersion) => version -> mcVersion } - } - - APIV2.PromotedVersion( - version, - Seq( - APIV2.PromotedVersionTag( - tagName, - data, - displayAndMc.flatMap(_._1), - displayAndMc.flatMap(_._2), - APIV2.VersionTagColor( - color.foreground, - color.background - ) - ) - ) ) - } + case "forge" => + lowerBoundVersion.flatMap { + //This will crash and burn if the implementation becomes + //something else, but better that, than failing silently + case version: DefaultArtifactVersion => + if (BigInt(version.version.getFirstInteger) >= 28) { + Some(version.toString) //Not sure what we really want to do here + } else { + version.toString match { + case OldForgeVersion(version) => Some(version) + case _ => None + } + } + } -> None //TODO + case _ => None -> None } - } yield res + } + + APIV2.VersionPlatform( + platform, + platformVersion.filterNot(_ == "null"), + displayAndMc.flatMap(_._1), + displayAndMc.flatMap(_._2) + ) + } } case class APIV2QueryCompactProject( @@ -238,20 +241,21 @@ case class APIV2QueryCompactProject( } } -case class APIV2QueryProjectMember( +case class APIV2QueryMember( user: String, - roles: List[Role] + role: Role, + isAccepted: Boolean ) { - def asProtocol: APIV2.ProjectMember = APIV2.ProjectMember( + def asProtocol: APIV2.Member = APIV2.Member( user, - roles.map { role => - APIV2.Role( - role.value, - role.title, - role.color.hex - ) - } + APIV2.Role( + role, + role.title, + role.color.hex, + role.permissions.toNamedSeq.toList, + isAccepted + ) ) } @@ -259,49 +263,48 @@ case class APIV2QueryVersion( createdAt: OffsetDateTime, name: String, dependenciesIds: List[String], + dependenciesVersions: List[Option[String]], visibility: Visibility, - description: Option[String], downloads: Long, fileSize: Long, md5Hash: String, fileName: String, authorName: Option[String], reviewState: ReviewState, - tags: List[APIV2QueryVersionTag] + mixin: Boolean, + stability: Stability, + releaseType: Option[ReleaseType], + platforms: List[String], + platformVersions: List[Option[String]], + postId: Option[Int] ) { def asProtocol: APIV2.Version = APIV2.Version( createdAt, name, - dependenciesIds.map { depId => - val data = depId.split(":") - APIV2.VersionDependency( - data(0), - data.lift(1) - ) + dependenciesIds.zip(dependenciesVersions).map { + case (id, Some("null")) => APIV2.VersionDependency(id, None) + case (id, Some(version)) => APIV2.VersionDependency(id, Some(version)) + case (id, None) => APIV2.VersionDependency(id, None) }, visibility, - description, APIV2.VersionStatsAll(downloads), - APIV2.FileInfo(name, fileSize, md5Hash), + APIV2.FileInfo(fileName, fileSize, md5Hash), authorName, reviewState, - tags.map(_.asProtocol) - ) -} - -case class APIV2QueryVersionTag( - name: String, - data: Option[String], - color: TagColor -) { - - def asProtocol: APIV2.VersionTag = APIV2.VersionTag( - name, - data, - APIV2.VersionTagColor( - color.foreground, - color.background + APIV2.VersionTags( + mixin, + stability, + releaseType, + platforms.zip(platformVersions).map { + case (platform, platformVersion) => + APIV2QueryProject.decodeVersionPlatform(platform, platformVersion) + } + ), + APIV2.VersionExternal( + APIV2.VersionExternalDiscourse( + postId + ) ) ) } @@ -311,6 +314,7 @@ case class APIV2QueryUser( name: String, tagline: Option[String], joinDate: Option[OffsetDateTime], + projectCount: Long, roles: List[Role] ) { @@ -319,16 +323,71 @@ case class APIV2QueryUser( name, tagline, joinDate, + projectCount, roles.map { role => APIV2.Role( - role.value, + role, role.title, - role.color.hex + role.color.hex, + role.permissions.toNamedSeq.toList, + isAccepted = true ) } ) } +case class APIV2QueryOrganization( + owner: String, + createdAt: OffsetDateTime, + name: String, + tagline: Option[String], + joinDate: Option[OffsetDateTime], + projectCount: Long, + roles: List[Role] +) { + + def asProtocol: APIV2.Organization = APIV2.Organization( + owner, + APIV2.User( + createdAt, + name, + tagline, + joinDate, + projectCount, + roles.map { role => + APIV2.Role( + role, + role.title, + role.color.hex, + role.permissions.toNamedSeq.toList, + isAccepted = true + ) + } + ) + ) +} + +case class APIV2QueryMembership( + scope: String, + organization: Option[String], + pluginId: Option[String], + ownerName: Option[String], + slug: Option[String], + role: Role, + isAccepted: Boolean +) { + + def asProtocol: APIV2.Membership = APIV2.Membership( + scope, + organization.map(APIV2.MembershipOrganization.apply), + pluginId + .zip(ownerName.zip(slug).map((APIV2.ProjectNamespace.apply _).tupled)) + .map((APIV2.MembershipProject.apply _).tupled), + role, + isAccepted + ) +} + case class APIV2ProjectStatsQuery( day: LocalDate, downloads: Long, diff --git a/apiV2/app/util/APIBinders.scala b/apiV2/app/util/APIBinders.scala index 0834c6e11..bc19088ef 100644 --- a/apiV2/app/util/APIBinders.scala +++ b/apiV2/app/util/APIBinders.scala @@ -2,39 +2,41 @@ package util import play.api.mvc.QueryStringBindable +import controllers.apiv2.Users.UserSortingStrategy import ore.data.project.Category -import ore.models.project.ProjectSortingStrategy +import ore.models.project.{ProjectSortingStrategy, Version} import ore.permission.NamedPermission +import ore.permission.role.Role object APIBinders { - implicit val categoryQueryStringBindable: QueryStringBindable[Category] = new QueryStringBindable[Category] { - override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, Category]] = - params.get(key).flatMap(_.headOption).map { s => - Category.values.find(_.apiName == s).toRight(s"$s is not a valid category") - } + private def objBindable[A](name: String, decode: String => Option[A], encode: A => String) = + new QueryStringBindable[A] { + override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, A]] = + params.get(key).flatMap(_.headOption).map(str => decode(str).toRight(s"$str is not a valid $name")) - override def unbind(key: String, value: Category): String = s"$key=${value.apiName}" - } + override def unbind(key: String, value: A): String = s"$key=${encode(value)}" + } - implicit val namedPermissionQueryStringBindable: QueryStringBindable[NamedPermission] = - new QueryStringBindable[NamedPermission] { - override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, NamedPermission]] = - params.get(key).flatMap(_.headOption).map { s => - NamedPermission.withNameOption(s).toRight(s"$s is not a valid permission") - } + implicit val categoryQueryStringBindable: QueryStringBindable[Category] = + objBindable("category", s => Category.values.find(_.apiName == s), _.apiName) - override def unbind(key: String, value: NamedPermission): String = s"$key=${value.entryName}" - } + implicit val namedPermissionQueryStringBindable: QueryStringBindable[NamedPermission] = + objBindable("permission", NamedPermission.withNameOption, _.entryName) implicit val projectSortingStrategyQueryStringBindable: QueryStringBindable[ProjectSortingStrategy] = - new QueryStringBindable[ProjectSortingStrategy] { - override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, ProjectSortingStrategy]] = - params.get(key).flatMap(_.headOption).map { s => - ProjectSortingStrategy.values.find(_.apiName == s).toRight(s"$s is not a valid sorting strategy") - } + objBindable("sorting strategy", s => ProjectSortingStrategy.values.find(_.apiName == s), _.apiName) - override def unbind(key: String, value: ProjectSortingStrategy): String = s"$key=${value.apiName}" - } + implicit val userSortingStrategyQueryStringBindable: QueryStringBindable[UserSortingStrategy] = + objBindable("sorting strategy", UserSortingStrategy.withValueOpt, _.value) + + implicit val stabilityStringBindable: QueryStringBindable[Version.Stability] = + objBindable("stability", Version.Stability.withValueOpt, _.value) + + implicit val releaseTypeStringBindable: QueryStringBindable[Version.ReleaseType] = + objBindable("release type", Version.ReleaseType.withValueOpt, _.value) + + implicit val roleStringBindable: QueryStringBindable[Role] = + objBindable("role", Role.withValueOpt, _.value) } diff --git a/apiV2/app/util/PartialUtils.scala b/apiV2/app/util/PartialUtils.scala new file mode 100644 index 000000000..fe518d615 --- /dev/null +++ b/apiV2/app/util/PartialUtils.scala @@ -0,0 +1,69 @@ +package util + +import cats.data.{Validated, ValidatedNel} +import cats.syntax.all._ +import io.circe.Decoder.AccumulatingResult +import io.circe.{Decoder, HCursor} +import perspective._ +import perspective.syntax.all._ + +object PartialUtils { + type PatchResult[A] = Decoder.AccumulatingResult[Option[A]] + type ValidateResult[A] = ValidatedNel[String, Option[A]] + + type Validator[A] = A => ValidatedNel[String, A] + object Validator { + def noValidation[A](value: A): ValidatedNel[String, A] = Validated.valid(value) + + def invaidIfEmpty(field: String): Validator[Option[String]] = { + case None => Validated.valid(None) + case Some("") => Validated.invalidNel(s"Passed empty string to $field") + case Some(value) => Validated.valid(Some(value)) + } + + def validIfEmpty[A](validator: Validator[A]): Validator[Option[A]] = { + case None => Validated.valid(None) + case Some(v) => validator(v).map(Some.apply) + } + + def checkLength(field: String, maxLength: Int): Validator[String] = + name => Validated.condNel(name.length <= maxLength, name, s"${field.capitalize} too long. Max length $maxLength") + + def allValid[A](validators: Validator[A]*): Validator[A] = + a => validators.map(f => f(a)).foldLeft(Validated.validNel[String, A](a))((acc, v) => acc *> v) + } + + def decodeAll(root: HCursor): PatchDecoder ~>: Compose2[AccumulatingResult, Option, *] = + Lambda[PatchDecoder ~>: Compose2[Decoder.AccumulatingResult, Option, *]](_.decode(root)) + + val validateAll: Tuple2K[PatchResult, Validator, *] ~>: ValidateResult = + new (Tuple2K[PatchResult, Validator, *] ~>: ValidateResult) { + override def apply[Z](fa: (PatchResult[Z], Validator[Z])): ValidateResult[Z] = { + import cats.instances.option._ + val progress = fa._1 + val validator = fa._2 + + progress.leftMap(_.map(_.show)).andThen(_.traverse(validator)) + } + } + + def decodeAndValidate[F[_[_]]]( + decoders: F[PatchDecoder], + validators: F[Validator], + root: HCursor + )(implicit FA: ApplicativeKC[F], FT: TraverseKC[F]): ValidatedNel[String, F[Option]] = + FT.sequenceK( + FA.map2K(FA.mapK(decoders)(decodeAll(root)), validators)(validateAll) + ) + + def toListK[F[_[_]], A](values: F[Const[A, *]])(implicit F: FoldableKC[F]): List[A] = { + import cats.instances.list._ + F.foldMapK(values)(FunctionK.liftConst(a => List(a))) + } + + def countDefined[F[_[_]]](values: F[Option])(implicit F: FoldableKC[F]): Int = { + import cats.instances.int._ + F.foldMapK(values)(λ[Option ~>: Const[Int, *]](fa => if (fa.isDefined) 1 else 0)) + } + +} diff --git a/apiV2/app/util/PatchDecoder.scala b/apiV2/app/util/PatchDecoder.scala new file mode 100644 index 000000000..e7c7a61e7 --- /dev/null +++ b/apiV2/app/util/PatchDecoder.scala @@ -0,0 +1,31 @@ +package util + +import cats.syntax.all._ +import io.circe.{ACursor, Decoder} +import perspective._ +import perspective.syntax.all._ + +trait PatchDecoder[A] { + + def decode(cursor: ACursor): Decoder.AccumulatingResult[Option[A]] +} +object PatchDecoder { + + def mkPath[A: Decoder](path: String*): PatchDecoder[A] = (cursor: ACursor) => { + import cats.instances.either._ + import cats.instances.option._ + + val cursorWithPath = path.foldLeft(cursor)(_.downField(_)) + + val res = if (cursorWithPath.succeeded) Some(cursorWithPath.as[A]) else None + + res.sequence.toValidatedNel + } + + def fromName[F[_[_]]: FunctorKC]( + fsd: F[Tuple2K[Const[List[String], *], Decoder, *]] + )(nameTransform: String => String): F[PatchDecoder] = + fsd.mapKC( + λ[Tuple2K[Const[List[String], *], Decoder, *] ~>: PatchDecoder](t => mkPath(t._1.map(nameTransform): _*)(t._2)) + ) +} diff --git a/apiV2/conf/apiv2.routes b/apiV2/conf/apiv2.routes index d93d23b23..086c8e498 100644 --- a/apiV2/conf/apiv2.routes +++ b/apiV2/conf/apiv2.routes @@ -22,14 +22,14 @@ # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.ApiSessionProperties' +# $ref: '#/components/schemas/controllers.apiv2.Authentication.ApiSessionProperties' # responses: # 200: # description: Ok # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.ReturnedApiSession' +# $ref: '#/components/schemas/controllers.apiv2.Authentication.ReturnedApiSession' # 400: # description: Sent if the requested expiration can't be used. # 401: @@ -40,10 +40,10 @@ # type: string ### +nocsrf -POST /authenticate @controllers.apiv2.ApiV2Controller.authenticate() +POST /authenticate @controllers.apiv2.Authentication.authenticate() ### NoDocs ### -POST /authenticate/user @controllers.apiv2.ApiV2Controller.authenticateUser() +POST /authenticate/user @controllers.apiv2.Authentication.authenticateUser() ### # summary: Invalidates the API session used for the request. @@ -62,7 +62,7 @@ POST /authenticate/user @controllers.apiv2. # $ref: '#/components/responses/ForbiddenError' ### +nocsrf -DELETE /sessions/current @controllers.apiv2.ApiV2Controller.deleteSession() +DELETE /sessions/current @controllers.apiv2.Authentication.deleteSession() ### # summary: Creates an API key @@ -74,14 +74,14 @@ DELETE /sessions/current @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.KeyToCreate' +# $ref: '#/components/schemas/controllers.apiv2.Keys.KeyToCreate' # responses: # 200: # description: Ok # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.CreatedApiKey' +# $ref: '#/components/schemas/controllers.apiv2.Keys.CreatedApiKey' # # 401: # $ref: '#/components/responses/UnauthorizedError' @@ -89,7 +89,7 @@ DELETE /sessions/current @controllers.apiv2. # $ref: '#/components/responses/ForbiddenError' ### +nocsrf -POST /keys @controllers.apiv2.ApiV2Controller.createKey() +POST /keys @controllers.apiv2.Keys.createKey() ### # summary: Delete an API key @@ -108,7 +108,7 @@ POST /keys @controllers.apiv2. # $ref: '#/components/responses/ForbiddenError' ### +nocsrf -DELETE /keys @controllers.apiv2.ApiV2Controller.deleteKey(name) +DELETE /keys @controllers.apiv2.Keys.deleteKey(name) @@ -118,21 +118,23 @@ DELETE /keys @controllers.apiv2. # tags: # - Permissions # parameters: -# - name: pluginId -# description: The plugin to check permissions in. Must not be used together with `organizationName` +# - name: projectOwner +# description: The owner of the project to get the permissions for. Must not be used together with `organizationName` +# - name: projectSlug +# description: The project slug of the project get the permissions for. Must not be used together with `organizationName` # - name: organizationName -# description: The organization to check permissions in. Must not be used together with `pluginId` +# description: The organization to check permissions in. Must not be used together with `projectOwner` and `projectSlug` # responses: # 200: # description: Ok # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.KeyPermissions' +# $ref: '#/components/schemas/controllers.apiv2.Permissions.KeyPermissions' # 401: # $ref: '#/components/responses/UnauthorizedError' ### -GET /permissions @controllers.apiv2.ApiV2Controller.showPermissions(pluginId: Option[String], organizationName: Option[String]) +GET /permissions @controllers.apiv2.Permissions.showPermissions(projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String]) ### # summary: Do an AND permission check @@ -142,21 +144,23 @@ GET /permissions @controllers.apiv2. # parameters: # - name: permissions # description: The permissions to check -# - name: pluginId -# description: The plugin to check permissions in. Must not be used together with `organizationName` +# - name: projectOwner +# description: The owner of the project to check permissions in. Must not be used together with `organizationName` +# - name: projectSlug +# description: The project slug of the project to check permissions in. Must not be used together with `organizationName` # - name: organizationName -# description: The organization to check permissions in. Must not be used together with `pluginId` +# description: The organization to check permissions in. Must not be used together with `projectOwner` and `projectSlug` # responses: # 200: # description: Ok # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PermissionCheck' +# $ref: '#/components/schemas/controllers.apiv2.Permissions.PermissionCheck' # 401: # $ref: '#/components/responses/UnauthorizedError' ### -GET /permissions/hasAll @controllers.apiv2.ApiV2Controller.hasAll(permissions: Seq[NamedPermission], pluginId: Option[String], organizationName: Option[String]) +GET /permissions/hasAll @controllers.apiv2.Permissions.hasAll(permissions: Seq[NamedPermission], projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String]) ### # summary: Do an OR permission check @@ -166,21 +170,23 @@ GET /permissions/hasAll @controllers.apiv2. # parameters: # - name: permissions # description: The permissions to check -# - name: pluginId -# description: The plugin to check permissions in. Must not be used together with `organizationName` +# - name: projectOwner +# description: The owner of the project to check permissions in. Must not be used together with `organizationName` +# - name: projectSlug +# description: The project slug of the project to check permissions in. Must not be used together with `organizationName` # - name: organizationName -# description: The organization to check permissions in. Must not be used together with `pluginId` +# description: The organization to check permissions in. Must not be used together with `projectOwner` and `projectSlug` # responses: # 200: # description: Ok # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PermissionCheck' +# $ref: '#/components/schemas/controllers.apiv2.Permissions.PermissionCheck' # 401: # $ref: '#/components/responses/UnauthorizedError' ### -GET /permissions/hasAny @controllers.apiv2.ApiV2Controller.hasAny(permissions: Seq[NamedPermission], pluginId: Option[String], organizationName: Option[String]) +GET /permissions/hasAny @controllers.apiv2.Permissions.hasAny(permissions: Seq[NamedPermission], projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String]) @@ -195,15 +201,19 @@ GET /permissions/hasAny @controllers.apiv2. # - name: categories # description: Restrict your search to a list of categories # required: false -# - name: tags +# - name: platforms # required: false -# description: A list of tags all the returned projects should have. Should be formated either as `tagname` or `tagname:tagdata`. +# description: Only show projects that have a promoted version with a platform given in this list. Should be formated either as `platform` or `platform:version`. +# - name: stability +# description: Only return projects that has a promoted version with the given stability # - name: owner # description: Limit the search to a specific user # - name: sort # description: How to sort the projects # - name: relevance # description: If how relevant the project is to the given query should be used when sorting the projects +# - name: exact +# description: If specified, changes the search to only look for exact mathes of the query to the project name. # - name: limit # description: The maximum amount of projects to return # - name: offset @@ -214,13 +224,39 @@ GET /permissions/hasAny @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PaginatedProjectResult' +# $ref: '#/components/schemas/controllers.apiv2.Projects.PaginatedProjectResult' # 401: # $ref: '#/components/responses/UnauthorizedError' # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects @controllers.apiv2.ApiV2Controller.listProjects(q: Option[String], categories: Seq[Category], tags: Seq[String], owner: Option[String], sort: Option[ProjectSortingStrategy], relevance: Option[Boolean], limit: Option[Long], offset: Long ?= 0) +GET /projects @controllers.apiv2.Projects.listProjects(q: Option[String], categories: Seq[Category], platforms: Seq[String], stability: Seq[Version.Stability], owner: Option[String], sort: Option[ProjectSortingStrategy], relevance: Option[Boolean], exact: Option[Boolean], limit: Option[Long], offset: Long ?= 0) + +### +# summary: Creates a new project +# description: Creates a new project and returns it. Requires the `create_project` permission. +# tags: +# - Projects +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.Projects.ApiV2ProjectTemplate' +# responses: +# 201: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Project' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +POST /projects @controllers.apiv2.Projects.createProject() ### # summary: Returns info on a specific project @@ -228,8 +264,10 @@ GET /projects @controllers.apiv2. # tags: # - Projects # parameters: -# - name: pluginId -# description: The plugin id of the project to return +# - name: projectOwner +# description: The owner of the project to return +# - name: projectSlug +# description: The project slug of the project to return # responses: # 200: # description: Ok @@ -242,16 +280,148 @@ GET /projects @controllers.apiv2. # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId @controllers.apiv2.ApiV2Controller.showProject(pluginId) +GET /projects/:projectOwner/:projectSlug @controllers.apiv2.Projects.showProject(projectOwner, projectSlug) + +### +# summary: Edits an existing project +# description: Edits the editable parts of an existing project. Requires the `edit_subject_settings` permission. +# tags: +# - Projects +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# name: +# type: string +# namespace: +# type: object +# properties: +# owner: +# type: string +# category: +# $ref: '#/components/schemas/Category' +# summary: +# type: string +# nullable: true +# settings: +# type: object +# properties: +# keywords: +# type: array +# items: +# type: string +# maxItems: 5 +# uniqueItems: true +# homepage: +# type: string +# nullable: true +# issues: +# type: string +# nullable: true +# sources: +# type: string +# nullable: true +# support: +# type: string +# nullable: true +# license: +# type: object +# properties: +# name: +# type: string +# nullable: true +# url: +# type: string +# nullable: true +# forum_sync: +# type: boolean +# +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/models.protocols.APIV2.Project' +# +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +PATCH /projects/:projectOwner/:projectSlug @controllers.apiv2.Projects.editProject(projectOwner, projectSlug) + +### +# summary: Permanently deletes a project. +# description: >- +# Permanently deletes a project and everything associated with it. +# Requires the `hard_delete_project` permission. +# tags: +# - Projects +# parameters: +# - name: projectOwner +# description: The owner of the project to delete +# - name: projectSlug +# description: The project slug of the project to delete +# responses: +# 204: +# description: Deleted +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +DELETE /projects/:projectOwner/:projectSlug @controllers.apiv2.Projects.hardDeleteProject(projectOwner, projectSlug) + +### +# summary: Returns the description for a specific project +# description: >- +# Returns the long description shown on the home page for a project. +# Requires the `view_public_info` permission. +# tags: +# - Projects +# parameters: +# - name: projectOwner +# description: The owner of the project which description to return +# - name: projectSlug +# description: The project slug of project which description to return +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# type: object +# required: +# - description +# properties: +# description: +# type: string +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /projects/:projectOwner/:projectSlug/description @controllers.apiv2.Projects.showProjectDescription(projectOwner, projectSlug) ### # summary: Returns the members of a project -# description: Returns the members of a project. Requires the `view_public_info` permission. +# description: >- +# Returns the members of a project. Requires the `view_public_info` permission. +# Unless the user also has the 'manage_subject_members' permission, only accepted roles +# will be shown. # tags: # - Projects # parameters: -# - name: pluginId -# description: The plugin id of the project to return members for +# - name: projectOwner +# description: The owner of the project to return members for +# - name: projectSlug +# description: The project slug of project to return members for # - name: limit # description: The maximum amount of members to return # - name: offset @@ -262,13 +432,44 @@ GET /projects/:pluginId @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/models.protocols.APIV2.ProjectMember' +# type: array +# items: +# $ref: '#/components/schemas/models.protocols.APIV2.Member' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /projects/:projectOwner/:projectSlug/members @controllers.apiv2.Projects.showProjectMembers(projectOwner, projectSlug, limit: Option[Long], offset: Long ?= 0) + +### +# summary: Updates the members of a project +# description: >- +# Updates the members of a project. Requires the `manage_subject_members` permission. +# tags: +# - Projects +# parameters: +# - name: projectOwner +# description: The owner of the project to update members for +# - name: projectSlug +# description: The project slug of project to update members for +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/controllers.apiv2.helpers.Members.MemberUpdate' +# responses: +# 204: +# description: Ok # 401: # $ref: '#/components/responses/UnauthorizedError' # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId/members @controllers.apiv2.ApiV2Controller.showMembers(pluginId, limit: Option[Long], offset: Long ?= 0) +POST /projects/:projectOwner/:projectSlug/members @controllers.apiv2.Projects.updateProjectMembers(projectOwner, projectSlug) ### # summary: Returns the stats for a project @@ -278,14 +479,18 @@ GET /projects/:pluginId/members @controllers.apiv2. # tags: # - Projects # parameters: -# - name: pluginId -# description: The plugin id of the project to return the stats for +# - name: projectOwner +# description: The owner of the project to return the stats for +# - name: projectSlug +# description: The project slug of project to return the stats for # - name: fromDate # description: The first date to include in the result -# format: date +# schema: +# format: date # - name: toDate # description: The last date to include in the result -# format: date +# schema: +# format: date # responses: # 200: # description: Ok @@ -301,7 +506,66 @@ GET /projects/:pluginId/members @controllers.apiv2. # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId/stats @controllers.apiv2.ApiV2Controller.showProjectStats(pluginId, fromDate: String, toDate: String) +GET /projects/:projectOwner/:projectSlug/stats @controllers.apiv2.Projects.showProjectStats(projectOwner, projectSlug, fromDate: String, toDate: String) + +### +# summary: Sets a project's visibility. +# description: >- +# Sets a project's visibility. The required permissions vary depending on the wanted visibility. +# Having reviewer permission gurantees access to all visibilities no matter the circumstances. +# In all other cases these rules apply. +# - 'needsApproval' requires 'edit_settings', and that the current visibility is 'needsChanges'. +# - 'softDelete' requires 'delete_project'. +# tags: +# - Projects +# parameters: +# - name: projectOwner +# description: The owner of the project to change the visibility of +# - name: projectSlug +# description: The project slug of project to change the visibility of +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.helpers.EditVisibility' +# responses: +# 204: +# description: Project updated +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +POST /projects/:projectOwner/:projectSlug/visibility @controllers.apiv2.Projects.setProjectVisibility(projectOwner, projectSlug) + +### +# summary: Sets a project's discourse topic settings. +# description: >- +# Edit's a project's Discourse settings manually. +# Needs the `edit_admin_settings` permission. +# tags: +# - Projects +# parameters: +# - name: projectOwner +# description: The owner of the project to change the discourse settings of +# - name: projectSlug +# description: The project slug of project to change the discourse settings of +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.Projects.DiscourseModifyTopicSettings' +# responses: +# 204: +# description: Project updated +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +POST /projects/:projectOwner/:projectSlug/external/_discourse @controllers.apiv2.Projects.editProjectDiscourseSettings(projectOwner, projectSlug) @@ -311,11 +575,17 @@ GET /projects/:pluginId/stats @controllers.apiv2. # tags: # - Versions # parameters: -# - name: pluginId -# description: The plugin id of the project to return versions for -# - name: tags +# - name: projectOwner +# description: The owner of the project to return versions for +# - name: projectSlug +# description: The project slug of project to return versions for +# - name: platforms # required: false -# description: A list of tags all the returned versions should have. Should be formated either as `tagname` or `tagname:tagdata`. +# description: Only show versions that with a platform given in this list. Should be formated either as `platform` or `platform:version`. +# - name: stability +# description: Only show versions with the given stability +# - name: releaseType +# description: Only show versions with the given release type # - name: limit # description: The maximum amount of versions to return # - name: offset @@ -326,13 +596,13 @@ GET /projects/:pluginId/stats @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PaginatedVersionResult' +# $ref: '#/components/schemas/controllers.apiv2.Versions.PaginatedVersionResult' # 401: # $ref: '#/components/responses/UnauthorizedError' # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId/versions @controllers.apiv2.ApiV2Controller.listVersions(pluginId, tags: Seq[String], limit: Option[Long], offset: Long ?= 0) +GET /projects/:projectOwner/:projectSlug/versions @controllers.apiv2.Versions.listVersions(projectOwner, projectSlug, platforms: Seq[String], stability: Seq[Version.Stability], releaseType: Seq[Version.ReleaseType], limit: Option[Long], offset: Long ?= 0) ### # summary: Returns a specific version of a project @@ -340,8 +610,10 @@ GET /projects/:pluginId/versions @controllers.apiv2. # tags: # - Versions # parameters: -# - name: pluginId -# description: The plugin id of the project to return the version for +# - name: projectOwner +# description: The owner of the project to return the version for +# - name: projectSlug +# description: The project slug of project to return the version for # - name: name # description: The name of the version to return # responses: @@ -356,7 +628,192 @@ GET /projects/:pluginId/versions @controllers.apiv2. # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId/versions/:name @controllers.apiv2.ApiV2Controller.showVersion(pluginId, name) +GET /projects/:projectOwner/:projectSlug/versions/:name @controllers.apiv2.Versions.showVersionAction(projectOwner, projectSlug, name) + +### +# summary: Edits an existing version +# description: >- +# Edits the editable parts of an existing version. Requires the +# `edit_version` permission. Tags are not part of this endpoint. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project whose version you will edit +# - name: projectSlug +# description: The project slug of project whose version you will edit +# - name: name +# description: The name of the version to edit +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# stability: +# $ref: '#/components/schemas/Stability' +# release_type: +# nullable: true +# oneOf: +# - $ref: '#/components/schemas/ReleaseType' +# platforms: +# type: array +# items: +# $ref: '#/components/schemas/controllers.apiv2.Versions.SimplePlatform' +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +PATCH /projects/:projectOwner/:projectSlug/versions/:name @controllers.apiv2.Versions.editVersion(projectOwner, projectSlug, name) + +### +# summary: Permanently deletes a version. +# description: >- +# Permanently deletes a version and everything associated with it. +# Requires the `hard_delete_version` permission. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project whose version you will delete +# - name: projectSlug +# description: The project slug of project whose version you will delete +# - name: name +# description: The name of the version to delete +# responses: +# 204: +# description: Deleted +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +DELETE /projects/:projectOwner/:projectSlug/versions/:name @controllers.apiv2.Versions.hardDeleteVersion(projectOwner, projectSlug, name) + +### +# summary: Sets a version's visibility. +# description: >- +# Sets a version's visibility. The required permissions vary depending on the wanted visibility. +# Having reviewer permission gurantees access to all visibilities no matter the circumstances. +# In all other cases these rules apply. +# - 'needsApproval' requires 'edit_settings', and that the current visibility is 'needsChanges'. +# - 'softDelete' requires 'delete_version'. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project whose version you will edit the visibility of +# - name: projectSlug +# description: The project slug of project whose version you will edit the visibility of +# - name: name +# description: The name of the version to edit +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.helpers.EditVisibility' +# responses: +# 204: +# description: Version updated +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +POST /projects/:projectOwner/:projectSlug/versions/:name/visibility @controllers.apiv2.Versions.setVersionVisibility(projectOwner, projectSlug, name) + +### +# summary: Returns the changelog for a version +# description: >- +# Returns the changelog for a version. Requires the `view_public_info` +# permission in the project or owning organization. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project to return the version changelog for +# - name: projectSlug +# description: The project slug of project to return the version changelog for +# - name: name +# description: The name of the version to return the changelog for +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /projects/:projectOwner/:projectSlug/versions/:name/changelog @controllers.apiv2.Versions.showVersionChangelog(projectOwner, projectSlug, name) + +### +# summary: Updates the changelog for a version +# description: >- +# Updates the changelog for a version. Requires the `edit_version` +# permission in the project or owning organization. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project to update the version changelog for +# - name: projectSlug +# description: The project slug of project to update the version changelog for +# - name: name +# description: The name of the version to update the changelog for +# responses: +# 204: +# description: Ok +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +PUT /projects/:projectOwner/:projectSlug/versions/:name/changelog @controllers.apiv2.Versions.updateChangelog(projectOwner, projectSlug, name) + +### +# summary: Sets a versions's discourse post settings. +# description: >- +# Edit's a version's Discourse settings manually. +# Needs the `edit_admin_settings` permission. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project which version to change the discourse settings of +# - name: projectSlug +# description: The project slug of project which version to change the discourse settings of +# - name: name +# description: The name of the version to update the settings of +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.Versions.DiscourseModifyPostSettings' +# responses: +# 204: +# description: Version updated +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +POST /projects/:projectOwner/:projectSlug/versions/:name/external/_discourse @controllers.apiv2.Versions.editVersionDiscourseSettings(projectOwner, projectSlug, name) ### # summary: Returns the stats for a version @@ -366,16 +823,20 @@ GET /projects/:pluginId/versions/:name @controllers.apiv2. # tags: # - Versions # parameters: -# - name: pluginId -# description: The plugin id of the version to return the stats for +# - name: projectOwner +# description: The owner of the project which version to return the stats for +# - name: projectSlug +# description: The project slug of project which version to return the stats for # - name: version # description: The version to return the stats for # - name: fromDate # description: The first date to include in the result -# format: date +# schema: +# format: date # - name: toDate # description: The last date to include in the result -# format: date +# schema: +# format: date # responses: # 200: # description: Ok @@ -391,7 +852,49 @@ GET /projects/:pluginId/versions/:name @controllers.apiv2. # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /projects/:pluginId/versions/:version/stats @controllers.apiv2.ApiV2Controller.showVersionStats(pluginId, version, fromDate: String, toDate: String) +GET /projects/:projectOwner/:projectSlug/versions/:version/stats @controllers.apiv2.Versions.showVersionStats(projectOwner, projectSlug, version, fromDate: String, toDate: String) + +### +# summary: Scan a plugin file. +# description: >- +# Scan a plugin file for future upload. Use this before uploading a version to +# see which tags Ore will assign the file. Requires the `create_version` +# permission in the project or owning organization. +# tags: +# - Versions +# parameters: +# - name: projectOwner +# description: The owner of the project to scan the file for +# - name: projectSlug +# description: The project slug of project to scan the file for +# requestBody: +# required: true +# content: +# multipart/form-data: +# schema: +# type: object +# required: +# - plugin-file +# properties: +# plugin-file: +# type: string +# format: binary +# description: The jar/zip file to upload +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +PUT /projects/:projectOwner/:projectSlug/versions/scan @controllers.apiv2.Versions.scanVersion(projectOwner, projectSlug) ### # summary: Creates a new version @@ -399,14 +902,19 @@ GET /projects/:pluginId/versions/:version/stats @controllers.apiv2. # tags: # - Versions # parameters: -# - name: pluginId -# description: The plugin id of the project to create the version for +# - name: projectOwner +# description: The owner of the project to create the version for +# - name: projectSlug +# description: The project slug of project to create the version for # requestBody: # required: true # content: # multipart/form-data: # schema: # type: object +# required: +# - plugin-file +# - plugin-info # properties: # plugin-info: # $ref: '#/components/schemas/DeployVersionInfo' @@ -431,9 +939,223 @@ GET /projects/:pluginId/versions/:version/stats @controllers.apiv2. # $ref: '#/components/responses/ForbiddenError' ### +nocsrf -POST /projects/:pluginId/versions @controllers.apiv2.ApiV2Controller.deployVersion(pluginId) +POST /projects/:projectOwner/:projectSlug/versions @controllers.apiv2.Versions.deployVersion(projectOwner, projectSlug) + +### +# summary: Returns all the pages for a project +# description: >- +# Returns a list of all the pages of the project. Requires the `view_public_info` +# permission in the project or owning organization. +# +# **WARNING: This API is subject to change, maybe very little, maybe dramatically.** +# tags: +# - Pages +# parameters: +# - name: projectOwner +# description: The owner of the project to return the pages for +# - name: projectSlug +# description: The project slug of project to return the pages for +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.PageList' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /projects/:projectOwner/:projectSlug/_pages @controllers.apiv2.Pages.showPages(projectOwner, projectSlug) + +### +# summary: Returns the given page +# description: >- +# Returns a page of the project. Requires the `view_public_info` +# permission in the project or owning organization. +# +# **WARNING: This API is subject to change, maybe very little, maybe dramatically.** +# tags: +# - Pages +# parameters: +# - name: projectOwner +# description: The owner of the project to return the page for +# - name: projectSlug +# description: The project slug of project to return the page for +# - name: page +# description: The slug of the page to return +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Page' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.showPageAction(projectOwner, projectSlug, page) + +### +# summary: Creates or updates a page +# description: >- +# Creates or updates a page in the given project. Requires the `edit_page` +# permission in the project or owning organization. +# +# **WARNING: This API is subject to change, maybe very little, maybe dramatically.** +# tags: +# - Pages +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Page' +# responses: +# 200: +# description: Page updated +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Page' +# 201: +# description: Page created +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Page' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +PUT /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.putPage(projectOwner, projectSlug, page) + +### +# summary: Updates a page +# description: >- +# Updates a page in the given project. Requires the `edit_page` +# permission in the project or owning organization. +# +# **WARNING: This API is subject to change, maybe very little, maybe dramatically.** +# tags: +# - Pages +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# name: +# type: string +# content: +# type: string +# nullable: true +# parent: +# type: string +# nullable: true +# responses: +# 200: +# description: Page updated +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Page' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +PATCH /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.patchPage(projectOwner, projectSlug, page) + +### +# summary: Deletes a page +# description: >- +# Deletes a page in the given project. Requires the `edit_page` +# permission in the project or owning organization. +# +# **WARNING: This API is subject to change, maybe very little, maybe dramatically.** +# tags: +# - Pages +# responses: +# 204: +# description: Page deleted +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +DELETE /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.deletePage(projectOwner, projectSlug, page) + +### NoDocs ### ++nocsrf +GET /projects/:projectOwner/:projectSlug/_projectData @controllers.apiv2.Projects.projectData(projectOwner, projectSlug) + +### NoDocs ### +GET /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) + +### +# summary: Searches the users on Ore +# description: Searches all the users on ore. Requires the `view_public_info` permission. +# tags: +# - Users +# parameters: +# - name: q +# description: The query to use when searching +# - name: minProjects +# description: Minimum amount of projects a user needs to have to be returned +# - name: roles +# description: The required roles a user needs to have to be returned +# - name: excludeOrganizations +# description: If organizations should be excluded from the search. Default is false +# - name: sort +# description: How to sort the users +# - name: sortDescending +# description: If the result should be sorted in descending or ascending order. Ascending is default. +# - name: limit +# description: The maximum amount of projects to return +# - name: offset +# description: Where to start searching +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.Users.PaginatedUserResult' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /users @controllers.apiv2.Users.listUsers(q: Option[String], minProjects: Option[Long], roles: Seq[ore.permission.role.Role], excludeOrganizations: Boolean ?= false, sort: Option[controllers.apiv2.Users.UserSortingStrategy], sortDescending: Boolean ?= false, limit: Option[Long], offset: Long ?= 0) -#GET /projects/:pluginId/pages @controllers.ApiV2Controller.listPages(pluginId, parentId: Option[DbRef[Page]]) + +### +# summary: Gets a current logged in user +# description: Gets a current logged in user. Requires the `view_public_info` permission. +# tags: +# - Users +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.User' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /users/@me @controllers.apiv2.Users.showCurrentUser() ### # summary: Gets a specific user @@ -455,7 +1177,33 @@ POST /projects/:pluginId/versions @controllers.apiv2. # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /users/:user @controllers.apiv2.ApiV2Controller.showUser(user) +GET /users/:user @controllers.apiv2.Users.showUser(user) + +### +# summary: Gets a specific user's memberships +# description: >- +# Gets all the areas where a user is a member. +# Requires the `view_public_info` permission. +# tags: +# - Users +# parameters: +# - name: user +# description: The user to return memberships for +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/models.protocols.APIV2.Membership' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /users/:user/memberships @controllers.apiv2.Users.getMemberships(user) ### # summary: Gets the starred projects for a specific user @@ -477,13 +1225,13 @@ GET /users/:user @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PaginatedCompactProjectResult' +# $ref: '#/components/schemas/controllers.apiv2.Users.PaginatedCompactProjectResult' # 401: # $ref: '#/components/responses/UnauthorizedError' # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /users/:user/starred @controllers.apiv2.ApiV2Controller.showStarred(user, sort: Option[ProjectSortingStrategy], limit: Option[Long], offset: Long ?= 0) +GET /users/:user/starred @controllers.apiv2.Users.showStarred(user, sort: Option[ProjectSortingStrategy], limit: Option[Long], offset: Long ?= 0) ### # summary: Gets the watched projects for a specific user @@ -505,10 +1253,93 @@ GET /users/:user/starred @controllers.apiv2. # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.ApiV2Controller.PaginatedCompactProjectResult' +# $ref: '#/components/schemas/controllers.apiv2.Users.PaginatedCompactProjectResult' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /users/:user/watching @controllers.apiv2.Users.showWatching(user, sort: Option[ProjectSortingStrategy], limit: Option[Long], offset: Long ?= 0) + +### NoDocs ### +GET /_headerdata @controllers.apiv2.Users.showHeaderData() + +### +# summary: Gets a specific organization +# description: Gets a specific organization. Requires the `view_public_info` permission. +# tags: +# - Organizations +# parameters: +# - name: organization +# description: The organization to return +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Organization' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /organizations/:organization @controllers.apiv2.Organizations.showOrganization(organization) + +### +# summary: Returns the members of an organization +# description: >- +# Returns the members of an organization. Requires the `view_public_info` permission. +# Unless the user also has the 'manage_subject_members' permission, only accepted roles +# will be shown. +# tags: +# - Organizations +# parameters: +# - name: organization +# description: The organization to return members for +# - name: limit +# description: The maximum amount of members to return +# - name: offset +# description: Where to start returning +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/models.protocols.APIV2.Member' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### +GET /organizations/:organization/members @controllers.apiv2.Organizations.showOrgMembers(organization, limit: Option[Long], offset: Long ?= 0) + +### +# summary: Updates the members of an organization +# description: >- +# Updates the members of an organization. Requires the `manage_subject_members` permission. +# tags: +# - Organizations +# parameters: +# - name: organization +# description: The organization to update members for +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/controllers.apiv2.helpers.Members.MemberUpdate' +# responses: +# 204: +# description: Ok # 401: # $ref: '#/components/responses/UnauthorizedError' # 403: # $ref: '#/components/responses/ForbiddenError' ### -GET /users/:user/watching @controllers.apiv2.ApiV2Controller.showWatching(user, sort: Option[ProjectSortingStrategy], limit: Option[Long], offset: Long ?= 0) \ No newline at end of file +POST /organizations/:organization/members @controllers.apiv2.Organizations.updateOrgMembers(organization) diff --git a/auth/src/main/scala/ore/auth/AkkaSSOApi.scala b/auth/src/main/scala/ore/auth/AkkaSSOApi.scala index 26a58bc0c..3f6a7fff4 100644 --- a/auth/src/main/scala/ore/auth/AkkaSSOApi.scala +++ b/auth/src/main/scala/ore/auth/AkkaSSOApi.scala @@ -16,8 +16,8 @@ import akka.http.scaladsl.model.{HttpMethods, HttpRequest, Uri} import akka.stream.Materializer import cats.Monad import cats.data.OptionT -import cats.effect.{Concurrent, Timer} import cats.effect.syntax.all._ +import cats.effect.{Concurrent, Timer} import cats.syntax.all._ import com.typesafe.scalalogging diff --git a/build.sbt b/build.sbt old mode 100755 new mode 100644 index ff09f18b3..ddf117e53 --- a/build.sbt +++ b/build.sbt @@ -23,6 +23,8 @@ lazy val externalCommon = project.settings( Deps.circe, Deps.circeDerivation, Deps.circeParser, + Deps.circeYaml, + Deps.tomlScala, Deps.akkaHttp, Deps.akkaHttpCore, Deps.akkaStream, @@ -122,61 +124,33 @@ lazy val apiV2 = project Deps.circeDerivation, Deps.circeParser, Deps.scalaCache, - Deps.scalaCacheCatsEffect + Deps.scalaCacheCatsEffect, + Deps.perspectiveDerivation, + Deps.perspectiveMacros ), libraryDependencies ++= Deps.playTestDeps ) lazy val oreClient = project - .enablePlugins(ScalaJSBundlerPlugin) + .enablePlugins(WebpackPlugin) .settings( Settings.commonSettings, name := "ore-client", - useYarn := true, - scalaJSUseMainModuleInitializer := false, - scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }, - fastOptJS / webpackConfigFile := Some(baseDirectory.value / "webpack.config.dev.js"), - fullOptJS / webpackConfigFile := Some(baseDirectory.value / "webpack.config.prod.js"), - webpackMonitoredDirectories += baseDirectory.value / "assets", - webpackMonitoredFiles / includeFilter := "*.vue" || "*.js", - fastOptJS / webpackBundlingMode := BundlingMode.LibraryOnly(), - fullOptJS / webpackBundlingMode := BundlingMode.LibraryOnly(), - startWebpackDevServer / version := NPMDeps.webpackDevServer, - webpack / version := NPMDeps.webpack, - Compile / npmDependencies ++= Seq( - NPMDeps.vue, - NPMDeps.lodash, - NPMDeps.queryString, - NPMDeps.fontAwesome, - NPMDeps.fontAwesomeSolid, - NPMDeps.fontAwesomeRegular, - NPMDeps.fontAwesomeBrands + Assets / webpackDevConfig := baseDirectory.value / "webpack.config.dev.js", + Assets / webpackProdConfig := baseDirectory.value / "webpack.config.prod.js", + Assets / webpackMonitoredDirectories += baseDirectory.value / "src" / "main" / "assets", + Assets / webpackMonitoredFiles / includeFilter := "*.vue" || "*.js", + Assets / webpackMonitoredFiles ++= Seq( + baseDirectory.value / "webpack.config.common.js", + baseDirectory.value / ".postcssrc.js", + baseDirectory.value / ".browserlistrc" ), - Compile / npmDevDependencies ++= Seq( - NPMDeps.webpackMerge, - NPMDeps.vueLoader, - NPMDeps.vueTemplateCompiler, - NPMDeps.postcss, - NPMDeps.cssLoader, - NPMDeps.vueStyleLoader, - NPMDeps.babelLoader, - NPMDeps.babel, - NPMDeps.babelPresetEnv, - NPMDeps.webpackTerser, - NPMDeps.miniCssExtractor, - NPMDeps.optimizeCssAssets, - NPMDeps.sassLoader, - NPMDeps.postCssLoader, - NPMDeps.autoprefixer, - NPMDeps.sass, - NPMDeps.webpackCopy, - NPMDeps.webpackBundleAnalyzer - ) + pipelineStages := Seq(digest, gzip) ) lazy val ore = project - .enablePlugins(PlayScala, SwaggerPlugin, WebScalaJSBundlerPlugin, BuildInfoPlugin) - .dependsOn(orePlayCommon, apiV2) + .enablePlugins(PlayScala, SwaggerPlugin, BuildInfoPlugin) + .dependsOn(orePlayCommon, apiV2, oreClient) .settings( Settings.commonSettings, Settings.playCommonSettings, @@ -195,10 +169,7 @@ lazy val ore = project libraryDependencies ++= Deps.flexmarkDeps, libraryDependencies ++= Seq( WebjarsDeps.jQuery, - WebjarsDeps.fontAwesome, - WebjarsDeps.filesize, WebjarsDeps.moment, - WebjarsDeps.clipboard, WebjarsDeps.chartJs, WebjarsDeps.swaggerUI ), @@ -206,17 +177,23 @@ lazy val ore = project swaggerRoutesFile := "apiv2.routes", swaggerDomainNameSpaces := Seq( "models.protocols.APIV2", - "controllers.apiv2.ApiV2Controller" + "controllers.apiv2.AbstractApiV2Controller", + "controllers.apiv2.Users", + "controllers.apiv2.Authentication", + "controllers.apiv2.Keys", + "controllers.apiv2.Permissions", + "controllers.apiv2.Projects", + "controllers.apiv2.Users", + "controllers.apiv2.Versions", + "controllers.apiv2.Members", + "controllers.apiv2.helpers" ), swaggerNamingStrategy := "snake_case", swaggerAPIVersion := "2.0", swaggerV3 := true, PlayKeys.playMonitoredFiles += baseDirectory.value / "swagger.yml", PlayKeys.playMonitoredFiles += baseDirectory.value / "swagger-custom-mappings.yml", - scalaJSProjects := Seq(oreClient), - Assets / pipelineStages += scalaJSPipeline, Assets / WebKeys.exportedMappings := Seq(), - PlayKeys.playMonitoredFiles += (oreClient / baseDirectory).value / "assets", buildInfoKeys := Seq[BuildInfoKey](version, scalaVersion, resolvers, libraryDependencies), buildInfoOptions += BuildInfoOption.BuildTime, buildInfoPackage := "ore", diff --git a/db/src/main/scala/ore/db/OreProfile.scala b/db/src/main/scala/ore/db/OreProfile.scala index 8069daea8..b7a7bfc63 100644 --- a/db/src/main/scala/ore/db/OreProfile.scala +++ b/db/src/main/scala/ore/db/OreProfile.scala @@ -2,7 +2,7 @@ package ore.db import scala.language.implicitConversions -import java.time.{Instant, OffsetDateTime} +import java.time.OffsetDateTime import slick.jdbc.JdbcProfile diff --git a/extra/apiV2.http b/extra/apiV2.http index b7e609fc5..04053c6b4 100644 --- a/extra/apiV2.http +++ b/extra/apiV2.http @@ -30,7 +30,7 @@ POST http://localhost:9000/api/v2/authenticate Accept: application/json Content-Type: application/json -{"fake": true} +{"_fake": true} > {% client.global.set("api_session", response.body.session); %} @@ -76,32 +76,42 @@ Authorization: OreApi session={{api_session}} Accept: application/json ### Get single project -GET http://localhost:9000/api/v2/projects/nucleus +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus Authorization: OreApi session={{api_session}} Accept: application/json ### Get project members -GET http://localhost:9000/api/v2/projects/nucleus/members +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/members Authorization: OreApi session={{api_session}} Accept: application/json ### Get project stats -GET http://localhost:9000/api/v2/projects/nucleus/stats?fromDate=2019-06-01&toDate=2019-10-13 +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/stats?fromDate=2019-06-01&toDate=2019-10-13 Authorization: OreApi session={{api_session}} Accept: application/json ### Get project versions +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/versions +Authorization: OreApi session={{api_session}} +Accept: application/json + +### Test old pluginId GET GET http://localhost:9000/api/v2/projects/nucleus/versions Authorization: OreApi session={{api_session}} Accept: application/json ### Get single project versions -GET http://localhost:9000/api/v2/projects/nucleus/versions/1.9.0-S7.1 +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/versions/1.9.0-S7.1 +Authorization: OreApi session={{api_session}} +Accept: application/json + +### Get the description of a versions +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/versions/1.9.0-S7.1/description Authorization: OreApi session={{api_session}} Accept: application/json ### Get version stats -GET http://localhost:9000/api/v2/projects/nucleus/versions/1.9.0-S7.1/stats?fromDate=2019-06-01&toDate=2019-10-13 +GET http://localhost:9000/api/v2/projects/Nucleus/Nucleus/versions/1.9.0-S7.1/stats?fromDate=2019-06-01&toDate=2019-10-13 Authorization: OreApi session={{api_session}} Accept: application/json diff --git a/jobs/src/main/scala/ore/JobsProcessor.scala b/jobs/src/main/scala/ore/JobsProcessor.scala index 54cf982dc..884438f7c 100644 --- a/jobs/src/main/scala/ore/JobsProcessor.scala +++ b/jobs/src/main/scala/ore/JobsProcessor.scala @@ -6,12 +6,12 @@ import scala.concurrent.TimeoutException import scala.concurrent.duration._ import ore.db.access.ModelView -import ore.db.impl.schema.{ProjectTable, VersionTable} import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.schema.{ProjectTable, VersionTable} import ore.db.{Model, ObjId} import ore.discourse.DiscourseError -import ore.models.{Job, JobInfo} import ore.models.project.{Project, Version} +import ore.models.{Job, JobInfo} import akka.pattern.CircuitBreakerOpenException import com.typesafe.scalalogging diff --git a/jobs/src/main/scala/ore/OreJobProcessorMain.scala b/jobs/src/main/scala/ore/OreJobProcessorMain.scala index 115c74dc6..191e1b788 100644 --- a/jobs/src/main/scala/ore/OreJobProcessorMain.scala +++ b/jobs/src/main/scala/ore/OreJobProcessorMain.scala @@ -160,7 +160,7 @@ object OreJobProcessorMain extends zio.ManagedApp { .fromEither(OreJobsConfig.load) .flatMapError { es => Logger.error( - s"Failed to load config:${es.toList.map(e => s"${e.description} -> ${e.origin.fold("")(_.description)}").mkString("\n ", "\n ", "")}" + s"Failed to load config:\n${es.prettyPrint(1)}" ) ZManaged.succeed(ExitCode.failure) } diff --git a/jobs/src/main/scala/ore/db/impl/service/OreModelService.scala b/jobs/src/main/scala/ore/db/impl/service/OreModelService.scala index 62f986520..1f210bcdf 100644 --- a/jobs/src/main/scala/ore/db/impl/service/OreModelService.scala +++ b/jobs/src/main/scala/ore/db/impl/service/OreModelService.scala @@ -1,11 +1,11 @@ package ore.db.impl.service -import ore.db.{Model, ModelCompanion, ModelQuery, ModelService} import ore.db.impl.OrePostgresDriver +import ore.db.{Model, ModelCompanion, ModelQuery, ModelService} import cats.effect.{Clock, Sync} -import doobie.{ConnectionIO, Transactor} import doobie.syntax.all._ +import doobie.{ConnectionIO, Transactor} import slick.dbio.DBIO import slick.lifted.Rep import zio.interop.catz._ diff --git a/jobs/src/main/scala/ore/discourse/OreDiscourseApiEnabled.scala b/jobs/src/main/scala/ore/discourse/OreDiscourseApiEnabled.scala index cbf8db8ca..20d14ccd8 100644 --- a/jobs/src/main/scala/ore/discourse/OreDiscourseApiEnabled.scala +++ b/jobs/src/main/scala/ore/discourse/OreDiscourseApiEnabled.scala @@ -4,8 +4,8 @@ import java.text.MessageFormat import ore.OreJobsConfig import ore.db.access.ModelView -import ore.db.{Model, ModelService} import ore.db.impl.OrePostgresDriver.api._ +import ore.db.{Model, ModelService} import ore.models.project.{Page, Project, Version, Visibility} import ore.syntax._ @@ -72,7 +72,7 @@ class OreDiscourseApiEnabled[F[_]]( val res = for { homePage <- EitherT.right[DiscourseError](homePage(project)) - content = Templates.projectTopic(project, homePage.contents) + content = Templates.projectTopic(project, homePage.contents.getOrElse(sys.error("Homepage must have contents"))) topic <- EitherT(createTopicProgram(content)) // Topic created! // Catch some unexpected cases (should never happen) @@ -116,7 +116,7 @@ class OreDiscourseApiEnabled[F[_]]( val res = for { homePage <- EitherT.right[DiscourseError](homePage(project)) - content = Templates.projectTopic(project, homePage.contents) + content = Templates.projectTopic(project, homePage.contents.getOrElse(sys.error("Homepage must have contents"))) _ <- EitherT(updateTopicProgram) _ <- EitherT(updatePostProgram(content)) _ = MDCLogger.debug(s"Project topic updated for ${project.url}.") diff --git a/models/src/main/scala/ore/data/Platforms.scala b/models/src/main/scala/ore/data/Platforms.scala deleted file mode 100644 index b738bce20..000000000 --- a/models/src/main/scala/ore/data/Platforms.scala +++ /dev/null @@ -1,133 +0,0 @@ -package ore.data - -import scala.language.higherKinds - -import scala.collection.immutable - -import ore.data.project.Dependency -import ore.db.{DbRef, Model, ModelService} -import ore.models.project.{TagColor, Version, VersionTag} - -import enumeratum.values._ - -/** - * The Platform a plugin/mod runs on - * - * @author phase - */ -sealed abstract class Platform( - val value: Int, - val name: String, - val platformCategory: PlatformCategory, - val priority: Int, - val dependencyId: String, - val tagColor: TagColor, - val url: String -) extends IntEnumEntry { - - def createGhostTag(versionId: DbRef[Version], version: Option[String]): VersionTag = - VersionTag(versionId, name, version, tagColor) -} -object Platform extends IntEnum[Platform] { - - val values: immutable.IndexedSeq[Platform] = findValues - - case object Sponge - extends Platform( - 0, - "Sponge", - SpongeCategory, - 0, - "spongeapi", - TagColor.Sponge, - "https://spongepowered.org/downloads" - ) - - case object SpongeForge - extends Platform( - 2, - "SpongeForge", - SpongeCategory, - 2, - "spongeforge", - TagColor.SpongeForge, - "https://www.spongepowered.org/downloads/spongeforge" - ) - - case object SpongeVanilla - extends Platform( - 3, - "SpongeVanilla", - SpongeCategory, - 2, - "spongevanilla", - TagColor.SpongeVanilla, - "https://www.spongepowered.org/downloads/spongevanilla" - ) - - case object SpongeCommon - extends Platform( - 4, - "SpongeCommon", - SpongeCategory, - 1, - "sponge", - TagColor.SpongeCommon, - "https://www.spongepowered.org/downloads" - ) - - case object Lantern - extends Platform(5, "Lantern", SpongeCategory, 2, "lantern", TagColor.Lantern, "https://www.lanternpowered.org/") - - case object Forge - extends Platform(1, "Forge", ForgeCategory, 0, "forge", TagColor.Forge, "https://files.minecraftforge.net/") - - def getPlatforms(dependencyIds: Seq[String]): Seq[Platform] = { - Platform.values - .filter(p => dependencyIds.contains(p.dependencyId)) - .groupBy(_.platformCategory) - .flatMap(_._2.groupBy(_.priority).maxBy(_._1)._2) - .toSeq - } - - def ghostTags(versionId: DbRef[Version], dependencies: Seq[Dependency]): Seq[VersionTag] = - getPlatforms(dependencies.map(_.pluginId)) - .map(p => p.createGhostTag(versionId, dependencies.find(_.pluginId == p.dependencyId).get.version)) - - def createPlatformTags[F[_]](versionId: DbRef[Version], dependencies: Seq[Dependency])( - implicit service: ModelService[F] - ): F[Seq[Model[VersionTag]]] = service.bulkInsert(ghostTags(versionId, dependencies)) - -} - -/** - * The category of a platform. - * Examples would be - * - * Sponge <- SpongeAPI, SpongeForge, SpongeVanilla - * Forge <- Forge (maybe Rift if that doesn't die?) - * Bukkit <- Bukkit, Spigot, Paper - * Canary <- Canary, Neptune - * - * @author phase - */ -sealed trait PlatformCategory { - def name: String - def tagName: String - - def getPlatforms: Seq[Platform] = Platform.values.filter(_.platformCategory == this) -} - -case object SpongeCategory extends PlatformCategory { - val name = "Sponge Plugins" - val tagName = "Sponge" -} - -case object ForgeCategory extends PlatformCategory { - val name = "Forge Mods" - val tagName = "Forge" -} - -object PlatformCategory { - def getPlatformCategories: Seq[PlatformCategory] = Seq(SpongeCategory, ForgeCategory) -} diff --git a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala index 7b61377b1..d623f5eda 100644 --- a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala +++ b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala @@ -11,7 +11,7 @@ import ore.data.user.notification.NotificationType import ore.data.{Color, DownloadType, Prompt} import ore.db.OreProfile import ore.models.Job -import ore.models.project.{ReviewState, TagColor, Visibility} +import ore.models.project.{ReviewState, TagColor, Version, Visibility} import ore.models.user.{LoggedActionContext, LoggedActionType} import ore.permission.Permission import ore.permission.role.{Role, RoleCategory} @@ -85,6 +85,11 @@ trait OrePostgresDriver .asInstanceOf[BaseColumnType[LoggedActionContext[Ctx]]] // scalafix:ok implicit val reviewStateTypeMapper: BaseColumnType[ReviewState] = mappedColumnTypeForValueEnum(ReviewState) + implicit val stabilityTypeMapper: BaseColumnType[Version.Stability] = + pgEnumForValueEnum("STABILITY", Version.Stability) + implicit val releaseTypeTypeMapper: BaseColumnType[Version.ReleaseType] = + pgEnumForValueEnum("RELEASE_TYPE", Version.ReleaseType) + implicit val langTypeMapper: BaseColumnType[Locale] = MappedJdbcType.base[Locale, String](_.toLanguageTag, Locale.forLanguageTag) diff --git a/models/src/main/scala/ore/db/impl/common/Hideable.scala b/models/src/main/scala/ore/db/impl/common/Hideable.scala index 833150c5f..550a55e86 100644 --- a/models/src/main/scala/ore/db/impl/common/Hideable.scala +++ b/models/src/main/scala/ore/db/impl/common/Hideable.scala @@ -60,11 +60,17 @@ object Hideable { } class RawOps[M](private val m: M) extends AnyVal { + def hVisibility[F[_]](implicit hide: Hideable[F, M]): Visibility = hide.visibility(m) + def isDeleted[F[_]](implicit hide: Hideable[F, M]): Boolean = hide.isDeleted(m) } class ModelOps[M](private val m: Model[M]) extends AnyVal { + def hVisibility[F[_]](implicit hide: Hideable[F, M]): Visibility = hide.visibility(m) + + def isDeleted[F[_]](implicit hide: Hideable[F, M]): Boolean = hide.isDeleted(m) + def setVisibility[F[_]]( visibility: Visibility, comment: String, diff --git a/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala b/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala index fe67aafd0..d0f9dbb4d 100644 --- a/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala +++ b/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala @@ -17,7 +17,7 @@ import ore.data.{Color, DownloadType, Prompt} import ore.db.{DbRef, Model, ObjId, ObjOffsetDateTime} import ore.models.Job import ore.models.api.ApiKey -import ore.models.project.{ReviewState, TagColor, Visibility} +import ore.models.project.{ReviewState, TagColor, Version, Visibility} import ore.models.user.{LoggedActionContext, LoggedActionType, User} import ore.permission.Permission import ore.permission.role.{Role, RoleCategory} @@ -28,7 +28,6 @@ import com.typesafe.scalalogging import doobie._ import doobie.`enum`.JdbcType.{Char, Date, LongVarChar, Time, Timestamp, TimestampWithTimezone, VarChar} import doobie.enum.JdbcType -import doobie.implicits._ import doobie.postgres.implicits._ import doobie.util.meta.Meta import enumeratum.values._ @@ -191,6 +190,9 @@ trait DoobieOreProtocol { implicit val inetStringMeta: Meta[InetString] = Meta[InetAddress].timap(address => InetString(address.toString))(str => InetAddress.getByName(str.value)) + implicit val stabilityMeta: Meta[Version.Stability] = pgEnumEnumeratumMeta("STABILITY", Version.Stability) + implicit val releaseTypeMeta: Meta[Version.ReleaseType] = pgEnumEnumeratumMeta("RELEASE_TYPE", Version.ReleaseType) + implicit val permissionMeta: Meta[Permission] = Meta.Advanced.one[Permission]( JdbcType.Bit, @@ -239,8 +241,8 @@ trait DoobieOreProtocol { implicit val userModelRead: Read[Model[User]] = Read[ObjId[User] :: ObjOffsetDateTime :: Option[String] :: String :: Option[String] :: Option[String] :: Option[ OffsetDateTime - ] :: List[Prompt] :: Boolean :: Option[Locale] :: HNil].map { - case id :: createdAt :: fullName :: name :: email :: tagline :: joinDate :: readPrompts :: isLocked :: lang :: HNil => + ] :: List[Prompt] :: Option[Locale] :: HNil].map { + case id :: createdAt :: fullName :: name :: email :: tagline :: joinDate :: readPrompts :: lang :: HNil => Model( id, createdAt, @@ -252,7 +254,6 @@ trait DoobieOreProtocol { tagline, joinDate, readPrompts, - isLocked, lang ) ) @@ -261,12 +262,10 @@ trait DoobieOreProtocol { implicit val userModelOptRead: Read[Option[Model[User]]] = Read[Option[ObjId[User]] :: Option[ObjOffsetDateTime] :: Option[String] :: Option[String] :: Option[String] :: Option[ String - ] :: Option[OffsetDateTime] :: Option[List[Prompt]] :: Option[Boolean] :: Option[ + ] :: Option[OffsetDateTime] :: Option[List[Prompt]] :: Option[ Locale ] :: HNil].map { - case Some(id) :: Some(createdAt) :: fullName :: Some(name) :: email :: tagline :: joinDate :: Some(readPrompts) :: Some( - isLocked - ) :: lang :: HNil => + case Some(id) :: Some(createdAt) :: fullName :: Some(name) :: email :: tagline :: joinDate :: Some(readPrompts) :: lang :: HNil => Some( Model( id, @@ -279,7 +278,6 @@ trait DoobieOreProtocol { tagline, joinDate, readPrompts, - isLocked, lang ) ) diff --git a/models/src/main/scala/ore/db/impl/schema/ChannelTable.scala b/models/src/main/scala/ore/db/impl/schema/ChannelTable.scala deleted file mode 100644 index d0b430c28..000000000 --- a/models/src/main/scala/ore/db/impl/schema/ChannelTable.scala +++ /dev/null @@ -1,22 +0,0 @@ -package ore.db.impl.schema - -import ore.data.Color -import ore.db.DbRef -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.table.common.NameColumn -import ore.models.project.{Channel, Project} - -class ChannelTable(tag: Tag) extends ModelTable[Channel](tag, "project_channels") with NameColumn[Channel] { - - def color = column[Color]("color") - def projectId = column[DbRef[Project]]("project_id") - def isNonReviewed = column[Boolean]("is_non_reviewed") - - override def * = - (id.?, createdAt.?, (projectId, name, color, isNonReviewed)).<>( - mkApply((Channel.apply _).tupled), - mkUnapply( - Channel.unapply - ) - ) -} diff --git a/models/src/main/scala/ore/db/impl/schema/DbRoleTable.scala b/models/src/main/scala/ore/db/impl/schema/DbRoleTable.scala index 8cda2e647..ee5173eaa 100644 --- a/models/src/main/scala/ore/db/impl/schema/DbRoleTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/DbRoleTable.scala @@ -1,6 +1,6 @@ package ore.db.impl.schema -import java.time.{Instant, OffsetDateTime} +import java.time.OffsetDateTime import ore.db.impl.OrePostgresDriver.api._ import ore.db.{DbRef, Model, ObjId, ObjOffsetDateTime} diff --git a/models/src/main/scala/ore/db/impl/schema/OrganizationMembersTable.scala b/models/src/main/scala/ore/db/impl/schema/OrganizationMembersTable.scala deleted file mode 100644 index e07b69cf3..000000000 --- a/models/src/main/scala/ore/db/impl/schema/OrganizationMembersTable.scala +++ /dev/null @@ -1,14 +0,0 @@ -package ore.db.impl.schema - -import ore.db.DbRef -import ore.db.impl.OrePostgresDriver.api._ -import ore.models.organization.Organization -import ore.models.user.User - -class OrganizationMembersTable(tag: Tag) extends AssociativeTable[User, Organization](tag, "organization_members") { - - def userId = column[DbRef[User]]("user_id") - def organizationId = column[DbRef[Organization]]("organization_id") - - override def * = (userId, organizationId) -} diff --git a/models/src/main/scala/ore/db/impl/schema/PageTable.scala b/models/src/main/scala/ore/db/impl/schema/PageTable.scala index d2ce4456e..5297e124c 100644 --- a/models/src/main/scala/ore/db/impl/schema/PageTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/PageTable.scala @@ -10,7 +10,7 @@ class PageTable(tag: Tag) extends ModelTable[Page](tag, "project_pages") with Na def projectId = column[DbRef[Project]]("project_id") def parentId = column[Option[DbRef[Page]]]("parent_id") def slug = column[String]("slug") - def contents = column[String]("contents") + def contents = column[Option[String]]("contents") def isDeletable = column[Boolean]("is_deletable") override def * = diff --git a/models/src/main/scala/ore/db/impl/schema/ProjectMembersTable.scala b/models/src/main/scala/ore/db/impl/schema/ProjectMembersTable.scala deleted file mode 100644 index f812c05a8..000000000 --- a/models/src/main/scala/ore/db/impl/schema/ProjectMembersTable.scala +++ /dev/null @@ -1,14 +0,0 @@ -package ore.db.impl.schema - -import ore.db.DbRef -import ore.db.impl.OrePostgresDriver.api._ -import ore.models.project.Project -import ore.models.user.User - -class ProjectMembersTable(tag: Tag) extends AssociativeTable[User, Project](tag, "project_members") { - - def projectId = column[DbRef[Project]]("project_id") - def userId = column[DbRef[User]]("user_id") - - override def * = (userId, projectId) -} diff --git a/models/src/main/scala/ore/db/impl/schema/ProjectTable.scala b/models/src/main/scala/ore/db/impl/schema/ProjectTable.scala index 395b6bf1c..ed1dd39f9 100644 --- a/models/src/main/scala/ore/db/impl/schema/ProjectTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/ProjectTable.scala @@ -15,23 +15,22 @@ class ProjectTable(tag: Tag) with VisibilityColumn[Project] with DescriptionColumn[Project] { - def pluginId = column[String]("plugin_id") - def ownerName = column[String]("owner_name") - def ownerId = column[DbRef[User]]("owner_id") - def slug = column[String]("slug") - def recommendedVersionId = column[DbRef[Version]]("recommended_version_id") - def category = column[Category]("category") - def topicId = column[Option[Int]]("topic_id") - def postId = column[Int]("post_id") - def notes = column[Json]("notes") - def keywords = column[List[String]]("keywords") - def homepage = column[String]("homepage") - def issues = column[String]("issues") - def source = column[String]("source") - def support = column[String]("support") - def licenseName = column[String]("license_name") - def licenseUrl = column[String]("license_url") - def forumSync = column[Boolean]("forum_sync") + def pluginId = column[String]("plugin_id") + def ownerName = column[String]("owner_name") + def ownerId = column[DbRef[User]]("owner_id") + def slug = column[String]("slug") + def category = column[Category]("category") + def topicId = column[Option[Int]]("topic_id") + def postId = column[Int]("post_id") + def notes = column[Json]("notes") + def keywords = column[List[String]]("keywords") + def homepage = column[String]("homepage") + def issues = column[String]("issues") + def source = column[String]("source") + def support = column[String]("support") + def licenseName = column[String]("license_name") + def licenseUrl = column[String]("license_url") + def forumSync = column[Boolean]("forum_sync") def settings = ( @@ -54,8 +53,6 @@ class ProjectTable(tag: Tag) ownerName, ownerId, name, - slug, - recommendedVersionId.?, category, description.?, topicId, diff --git a/models/src/main/scala/ore/db/impl/schema/UserTable.scala b/models/src/main/scala/ore/db/impl/schema/UserTable.scala index abba81c53..21978d90e 100644 --- a/models/src/main/scala/ore/db/impl/schema/UserTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/UserTable.scala @@ -16,7 +16,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn def fullName = column[String]("full_name") def email = column[String]("email") - def isLocked = column[Boolean]("is_locked") def tagline = column[String]("tagline") def joinDate = column[OffsetDateTime]("join_date") def readPrompts = column[List[Prompt]]("read_prompts") @@ -33,11 +32,10 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn Option[String], Option[OffsetDateTime], List[Prompt], - Boolean, Option[Locale] ) ) => Model[User] = { - case (id, time, fullName, name, email, tagline, joinDate, prompts, locked, lang) => + case (id, time, fullName, name, email, tagline, joinDate, prompts, lang) => Model( ObjId.unsafeFromOption(id), ObjOffsetDateTime.unsafeFromOption(time), @@ -49,7 +47,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn tagline, joinDate, prompts, - locked, lang ) ) @@ -65,7 +62,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn Option[String], Option[OffsetDateTime], List[Prompt], - Boolean, Option[Locale] ) ] = { @@ -80,7 +76,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn tagline, joinDate, readPrompts, - isLocked, lang ) ) => @@ -94,7 +89,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn tagline, joinDate, readPrompts, - isLocked, lang ) ) @@ -109,7 +103,6 @@ class UserTable(tag: Tag) extends ModelTable[User](tag, "users") with NameColumn tagline.?, joinDate.?, readPrompts, - isLocked, lang.? ).<>(applyFunc, unapplyFunc) } diff --git a/models/src/main/scala/ore/db/impl/schema/VersionPlatformTable.scala b/models/src/main/scala/ore/db/impl/schema/VersionPlatformTable.scala new file mode 100644 index 000000000..3bca6f3e2 --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/VersionPlatformTable.scala @@ -0,0 +1,21 @@ +package ore.db.impl.schema + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.project.{Version, VersionPlatform} + +class VersionPlatformTable(tag: Tag) extends ModelTable[VersionPlatform](tag, "project_version_platforms") { + + def versionId = column[DbRef[Version]]("version_id") + def platform = column[String]("platform") + def platformVersion = column[Option[String]]("platform_version") + def platformCoarseVersion = column[Option[String]]("platform_coarse_version") + + override def * = + (id.?, createdAt.?, (versionId, platform, platformVersion, platformCoarseVersion)).<>( + mkApply( + (VersionPlatform.apply _).tupled + ), + mkUnapply(VersionPlatform.unapply) + ) +} diff --git a/models/src/main/scala/ore/db/impl/schema/VersionTable.scala b/models/src/main/scala/ore/db/impl/schema/VersionTable.scala index 9c43b3d44..99ce6c08d 100644 --- a/models/src/main/scala/ore/db/impl/schema/VersionTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/VersionTable.scala @@ -2,30 +2,58 @@ package ore.db.impl.schema import java.time.OffsetDateTime -import ore.db.DbRef +import scala.reflect.ClassTag + +import ore.db.{DbRef, impl} +import ore.db.impl.OrePostgresDriver import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.table.common.{DescriptionColumn, VisibilityColumn} -import ore.models.project.{Channel, Project, ReviewState, Version} +import ore.models.project.{Project, ReviewState, TagColor, Version} import ore.models.user.User +//noinspection MutatorLikeMethodIsParameterless class VersionTable(tag: Tag) extends ModelTable[Version](tag, "project_versions") with DescriptionColumn[Version] with VisibilityColumn[Version] { - def versionString = column[String]("version_string") - def dependencies = column[List[String]]("dependencies") - def projectId = column[DbRef[Project]]("project_id") - def channelId = column[DbRef[Channel]]("channel_id") - def fileSize = column[Long]("file_size") - def hash = column[String]("hash") - def authorId = column[DbRef[User]]("author_id") - def reviewStatus = column[ReviewState]("review_state") - def reviewerId = column[DbRef[User]]("reviewer_id") - def approvedAt = column[OffsetDateTime]("approved_at") - def fileName = column[String]("file_name") - def createForumPost = column[Boolean]("create_forum_post") - def postId = column[Option[Int]]("post_id") + implicit private val listOptionStrType: OrePostgresDriver.DriverJdbcType[List[Option[String]]] = + new OrePostgresDriver.SimpleArrayJdbcType[String]("text") + .to[List]( + //DANGER + _.map(Option(_)).toList.asInstanceOf[List[String]], + _.asInstanceOf[List[Option[String]]].map(_.orNull) + ) + .asInstanceOf[OrePostgresDriver.DriverJdbcType[List[Option[String]]]] + + def versionString = column[String]("version_string") + def dependencyIds = column[List[String]]("dependency_ids") + def dependencyVersions = column[List[Option[String]]]("dependency_versions") + def projectId = column[DbRef[Project]]("project_id") + def fileSize = column[Long]("file_size") + def hash = column[String]("hash") + def authorId = column[DbRef[User]]("author_id") + def reviewStatus = column[ReviewState]("review_state") + def reviewerId = column[DbRef[User]]("reviewer_id") + def approvedAt = column[OffsetDateTime]("approved_at") + def fileName = column[String]("file_name") + def createForumPost = column[Boolean]("create_forum_post") + def postId = column[Option[Int]]("post_id") + + def usesMixin = column[Boolean]("uses_mixin") + def stability = column[Version.Stability]("stability") + def releaseType = column[Version.ReleaseType]("release_type") + def channelName = column[String]("legacy_channel_name") + def channelColor = column[TagColor]("legacy_channel_color") + + def tags = + ( + usesMixin, + stability, + releaseType.?, + channelName.?, + channelColor.? + ).<>(Version.VersionTags.tupled, Version.VersionTags.unapply) override def * = ( @@ -34,8 +62,8 @@ class VersionTable(tag: Tag) ( projectId, versionString, - dependencies, - channelId, + dependencyIds, + dependencyVersions, fileSize, hash, authorId.?, @@ -46,7 +74,8 @@ class VersionTable(tag: Tag) visibility, fileName, createForumPost, - postId + postId, + tags ) ).<>(mkApply((Version.apply _).tupled), mkUnapply(Version.unapply)) } diff --git a/models/src/main/scala/ore/db/impl/schema/VersionTagTable.scala b/models/src/main/scala/ore/db/impl/schema/VersionTagTable.scala deleted file mode 100644 index c104094ec..000000000 --- a/models/src/main/scala/ore/db/impl/schema/VersionTagTable.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ore.db.impl.schema - -import java.time.OffsetDateTime - -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.table.common.NameColumn -import ore.db.{DbRef, Model, ObjId, ObjOffsetDateTime} -import ore.models.project.{TagColor, Version, VersionTag} - -class VersionTagTable(tag: Tag) - extends ModelTable[VersionTag](tag, "project_version_tags") - with NameColumn[VersionTag] { - - def versionId = column[DbRef[Version]]("version_id") - def data = column[String]("data") - def color = column[TagColor]("color") - - override def * = { - val convertedApply - : ((Option[DbRef[VersionTag]], DbRef[Version], String, Option[String], TagColor)) => Model[VersionTag] = { - case (id, versionIds, name, data, color) => - Model( - ObjId.unsafeFromOption(id), - ObjOffsetDateTime(OffsetDateTime.MIN), - VersionTag(versionIds, name, data, color) - ) - } - val convertedUnapply - : PartialFunction[Model[VersionTag], (Option[DbRef[VersionTag]], DbRef[Version], String, Option[String], TagColor)] = { - case Model(id, _, VersionTag(versionIds, name, data, color)) => - (id.unsafeToOption, versionIds, name, data, color) - } - (id.?, versionId, name, data.?, color).<>(convertedApply, convertedUnapply.lift) - } -} diff --git a/models/src/main/scala/ore/db/impl/schema/loggedActionTable.scala b/models/src/main/scala/ore/db/impl/schema/loggedActionTable.scala index fa317674a..dde2ce341 100644 --- a/models/src/main/scala/ore/db/impl/schema/loggedActionTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/loggedActionTable.scala @@ -4,16 +4,7 @@ import ore.db.DbRef import ore.db.impl.OrePostgresDriver.api._ import ore.models.organization.Organization import ore.models.project.{Page, Project, Version} -import ore.models.user.{ - LoggedActionCommon, - LoggedActionOrganization, - LoggedActionPage, - LoggedActionProject, - LoggedActionType, - LoggedActionUser, - LoggedActionVersion, - User -} +import ore.models.user._ import com.github.tminglei.slickpg.InetString diff --git a/models/src/main/scala/ore/member/MembershipDossier.scala b/models/src/main/scala/ore/member/MembershipDossier.scala index 7866481a7..b3abe1112 100644 --- a/models/src/main/scala/ore/member/MembershipDossier.scala +++ b/models/src/main/scala/ore/member/MembershipDossier.scala @@ -3,8 +3,7 @@ package ore.member import scala.language.{higherKinds, implicitConversions} import ore.db._ -import ore.db.access.ModelView.Now -import ore.db.access.{ModelAssociationAccess, ModelAssociationAccessImpl, ModelView} +import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema._ import ore.db.impl.table.common.RoleTable @@ -23,14 +22,15 @@ trait MembershipDossier[F[_], M] { type RoleType <: UserRoleModel[RoleType] type RoleTypeTable <: RoleTable[RoleType] - def roles(model: Model[M]): ModelView.Now[F, RoleTypeTable, Model[RoleType]] + def memberships(model: Model[M]): ModelView.Now[F, RoleTypeTable, Model[RoleType]] /** - * Clears the roles of a User + * Returns the ids all members of the model. This includes members that have not + * yet accepted membership. * - * @param user User instance + * @return All members */ - def clearRoles(model: Model[M])(user: DbRef[User]): F[Int] + def membersIds(model: Model[M]): F[Set[DbRef[User]]] /** * Returns all members of the model. This includes members that have not @@ -38,29 +38,22 @@ trait MembershipDossier[F[_], M] { * * @return All members */ - def members(model: Model[M]): F[Set[DbRef[User]]] + def members(model: Model[M]): F[Seq[Model[RoleType]]] /** * Adds a new role to the dossier and adds the user as a member if not already. * * @param role Role to add */ - def addRole(model: Model[M])(userId: DbRef[User], role: RoleType): F[RoleType] + def setRole(model: Model[M])(userId: DbRef[User], role: RoleType): F[RoleType] /** - * Returns all roles for the specified [[User]]. + * Returns membership for the specified [[User]]. * * @param user User to get roles for * @return User roles */ - def getRoles(model: Model[M])(user: DbRef[User]): F[Set[Model[RoleType]]] - - /** - * Removes a role from the dossier and removes the member if last role. - * - * @param role Role to remove - */ - def removeRole(model: Model[M])(role: DbRef[RoleType]): F[Unit] + def getMembership(model: Model[M])(user: DbRef[User]): F[Option[Model[RoleType]]] /** * Clears all user roles and removes the user from the dossier. @@ -85,61 +78,43 @@ object MembershipDossier { abstract class AbstractMembershipDossier[F[_], M, RoleType0 <: UserRoleModel[RoleType0], RoleTypeTable0 <: RoleTable[ RoleType0 - ], MembersTable <: AssociativeTable[User, M]]( - M: ModelCompanion[M], + ]]( RoleType: ModelCompanion.Aux[RoleType0, RoleTypeTable0] - )(implicit service: ModelService[F], F: Monad[F], assocQuery: AssociationQuery[MembersTable, User, M]) + )(implicit service: ModelService[F], F: Monad[F]) extends MembershipDossier[F, M] { override type RoleType = RoleType0 override type RoleTypeTable = RoleTypeTable0 - private def association: ModelAssociationAccess[MembersTable, User, M, UserTable, M.T, F] = - new ModelAssociationAccessImpl(ore.db.impl.OrePostgresDriver)(User, M) + override def membersIds(model: Model[M]): F[Set[DbRef[User]]] = + service.runDBIO(memberships(model).query.map(_.userId).to[Set].result) - private def addMember(model: DbRef[M], user: DbRef[User]) = - association.addAssoc(user, model) + override def members(model: Model[M]): F[Seq[Model[RoleType]]] = + service.runDBIO(memberships(model).query.to[Seq].result) - override def members(model: Model[M]): F[Set[DbRef[User]]] = - association - .allFromChild(model.id) - .map(_.map(_.id.value).toSet) - - override def addRole(model: Model[M])(userId: DbRef[User], role: RoleType): F[RoleType] = + override def setRole(model: Model[M])(userId: DbRef[User], role: RoleType): F[RoleType] = for { - exists <- roles(model).exists(_.userId === userId) - _ <- if (!exists) addMember(model.id, userId) else F.unit + exists <- memberships(model).exists(_.userId === userId) + _ <- if (exists) removeMember(model)(userId) else F.unit ret <- service.insertRaw(RoleType)(role) } yield ret - override def getRoles(model: Model[M])(user: DbRef[User]): F[Set[Model[RoleType]]] = - service.runDBIO(roles(model).filterView(_.userId === user).query.to[Set].result) - - override def removeRole(model: Model[M])(role: DbRef[RoleType]): F[Unit] = - for { - userId <- service.runDBIO(RoleType.baseQuery.filter(_.id === role).map(t => t.userId).result.head) - _ <- service.deleteWhere(RoleType)(_.id === role) - exists <- roles(model).exists(_.userId === userId) - _ <- if (!exists) removeMember(model)(userId) else F.unit - } yield () + override def getMembership(model: Model[M])(user: DbRef[User]): F[Option[Model[RoleType]]] = + memberships(model).find(_.userId === user).value override def removeMember(model: Model[M])(user: DbRef[User]): F[Unit] = - clearRoles(model)(user) *> association.removeAssoc(user, model.id) + service.runDBIO(memberships(model).query.filter(_.userId === user).delete).void } implicit def projectHasMemberships[F[_]]( implicit service: ModelService[F], F: Monad[F] ): MembershipDossier.Aux[F, Project, ProjectUserRole, ProjectRoleTable] = - new AbstractMembershipDossier[F, Project, ProjectUserRole, ProjectRoleTable, ProjectMembersTable]( - Project, + new AbstractMembershipDossier[F, Project, ProjectUserRole, ProjectRoleTable]( ProjectUserRole ) { - override def roles(model: Model[Project]): Now[F, ProjectRoleTable, Model[ProjectUserRole]] = + override def memberships(model: Model[Project]): ModelView.Now[F, ProjectRoleTable, Model[ProjectUserRole]] = ModelView.now(ProjectUserRole).filterView(_.projectId === model.id.value) - - override def clearRoles(model: Model[Project])(user: DbRef[User]): F[Int] = - service.deleteWhere(ProjectUserRole)(s => (s.userId === user) && (s.projectId === model.id.value)) } implicit def organizationHasMemberships[F[_]]( @@ -151,17 +126,14 @@ object MembershipDossier { Organization, OrganizationUserRole, OrganizationRoleTable, - OrganizationMembersTable ]( - Organization, OrganizationUserRole ) { - override def roles(model: Model[Organization]): Now[F, OrganizationRoleTable, Model[OrganizationUserRole]] = + override def memberships( + model: Model[Organization] + ): ModelView.Now[F, OrganizationRoleTable, Model[OrganizationUserRole]] = ModelView.now(OrganizationUserRole).filterView(_.organizationId === model.id.value) - - override def clearRoles(model: Model[Organization])(user: DbRef[User]): F[Int] = - service.deleteWhere(OrganizationUserRole)(s => (s.userId === user) && (s.organizationId === model.id.value)) } val STATUS_DECLINE = "decline" diff --git a/models/src/main/scala/ore/models/Job.scala b/models/src/main/scala/ore/models/Job.scala index 72391640f..faef189ad 100644 --- a/models/src/main/scala/ore/models/Job.scala +++ b/models/src/main/scala/ore/models/Job.scala @@ -2,9 +2,9 @@ package ore.models import java.time.OffsetDateTime -import ore.db.{DbRef, ModelQuery} import ore.db.impl.DefaultModelCompanion import ore.db.impl.schema.JobTable +import ore.db.{DbRef, ModelQuery} import ore.models.project.{Project, Version} import enumeratum.values._ diff --git a/models/src/main/scala/ore/models/admin/LoggedActionView.scala b/models/src/main/scala/ore/models/admin/LoggedActionView.scala index 689b739d8..2498bbe5a 100644 --- a/models/src/main/scala/ore/models/admin/LoggedActionView.scala +++ b/models/src/main/scala/ore/models/admin/LoggedActionView.scala @@ -1,12 +1,10 @@ package ore.models.admin import ore.db._ -import ore.db.impl.ModelCompanionPartial import ore.models.project.{Page, Project, Version} -import ore.models.user.{LoggedActionType, LoggedActionContext, User, UserOwned} +import ore.models.user.{LoggedActionContext, LoggedActionType, User} import com.github.tminglei.slickpg.InetString -import slick.lifted.TableQuery case class LoggedProject( id: Option[DbRef[Project]], diff --git a/models/src/main/scala/ore/models/organization/Organization.scala b/models/src/main/scala/ore/models/organization/Organization.scala index a35d3d5a3..9733bb236 100644 --- a/models/src/main/scala/ore/models/organization/Organization.scala +++ b/models/src/main/scala/ore/models/organization/Organization.scala @@ -61,7 +61,7 @@ object Organization extends ModelCompanionPartial[Organization, OrganizationTabl // Down-grade current owner to "Admin" val oldOwner = m.ownerId for { - t2 <- (memberships.getRoles(m)(oldOwner), memberships.getRoles(m)(newOwner)).parTupled + t2 <- (memberships.getMembership(m)(oldOwner), memberships.getMembership(m)(newOwner)).parTupled (roles, memberRoles) = t2 setOwner <- service.update(m)(_.copy(ownerId = newOwner)) _ <- roles diff --git a/models/src/main/scala/ore/models/project/Channel.scala b/models/src/main/scala/ore/models/project/Channel.scala deleted file mode 100644 index a554c06f7..000000000 --- a/models/src/main/scala/ore/models/project/Channel.scala +++ /dev/null @@ -1,62 +0,0 @@ -package ore.models.project - -import scala.language.higherKinds - -import ore.data.Color -import ore.data.Color._ -import ore.db.access.QueryView -import ore.db.impl.DefaultModelCompanion -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.common.Named -import ore.db.impl.schema.{ChannelTable, VersionTable} -import ore.db.{DbRef, Model, ModelQuery} -import ore.syntax._ - -import slick.lifted.TableQuery - -/** - * Represents a release channel for Project Versions. Each project gets it's - * own set of channels. - * - * @param isNonReviewed Whether this channel should be excluded from the staff - * approval queue - * @param name Name of channel - * @param color Color used to represent this Channel - * @param projectId ID of project this channel belongs to - */ -case class Channel( - projectId: DbRef[Project], - name: String, - color: Color, - isNonReviewed: Boolean = false -) extends Named { - - def isReviewed: Boolean = !isNonReviewed -} - -object Channel extends DefaultModelCompanion[Channel, ChannelTable](TableQuery[ChannelTable]) { - - implicit val channelsAreOrdered: Ordering[Channel] = (x: Channel, y: Channel) => x.name.compare(y.name) - - implicit val query: ModelQuery[Channel] = - ModelQuery.from(this) - - implicit val isProjectOwned: ProjectOwned[Channel] = (a: Channel) => a.projectId - - /** - * The colors a Channel is allowed to have. - */ - val Colors: Seq[Color] = - Seq(Purple, Violet, Magenta, Blue, Aqua, Cyan, Green, DarkGreen, Chartreuse, Amber, Orange, Red) - - implicit class ChannelModelOps(private val self: Model[Channel]) extends AnyVal { - - /** - * Returns all Versions in this channel. - * - * @return All versions - */ - def versions[V[_, _]: QueryView](view: V[VersionTable, Model[Version]]): V[VersionTable, Model[Version]] = - view.filterView(_.channelId === self.id.value) - } -} diff --git a/models/src/main/scala/ore/models/project/Page.scala b/models/src/main/scala/ore/models/project/Page.scala index efe39b405..660964116 100644 --- a/models/src/main/scala/ore/models/project/Page.scala +++ b/models/src/main/scala/ore/models/project/Page.scala @@ -29,7 +29,7 @@ case class Page private ( name: String, slug: String, isDeletable: Boolean, - contents: String + contents: Option[String] ) extends Named { /** @@ -56,14 +56,14 @@ object Page extends DefaultModelCompanion[Page, PageTable](TableQuery[PageTable] def apply( projectId: DbRef[Project], name: String, - content: String, + content: Option[String], isDeletable: Boolean, parentId: Option[DbRef[Page]] ): Page = Page( projectId = projectId, name = compact(name), slug = slugify(name), - contents = content.trim, + contents = content.map(_.trim), isDeletable = isDeletable, parentId = parentId ) diff --git a/models/src/main/scala/ore/models/project/Project.scala b/models/src/main/scala/ore/models/project/Project.scala index 2a6682851..688c4416b 100644 --- a/models/src/main/scala/ore/models/project/Project.scala +++ b/models/src/main/scala/ore/models/project/Project.scala @@ -16,21 +16,19 @@ import ore.member.{Joinable, MembershipDossier} import ore.models.admin.ProjectVisibilityChange import ore.models.api.ProjectApiKey import ore.models.project.Project.ProjectSettings -import ore.models.statistic.ProjectView import ore.models.user.role.ProjectUserRole import ore.models.user.{User, UserOwned} import ore.permission.role.Role import ore.permission.scope.HasScope import ore.syntax._ -import ore.util.StringLocaleFormatterUtils +import ore.util.{StringLocaleFormatterUtils, StringUtils} import cats.syntax.all._ import cats.{Functor, Monad, MonadError, Parallel} import io.circe.Json import io.circe.generic.JsonCodec import io.circe.syntax._ -import slick.lifted -import slick.lifted.{Rep, TableQuery} +import slick.lifted.TableQuery /** * Represents an Ore package. @@ -41,11 +39,8 @@ import slick.lifted.{Rep, TableQuery} * @param ownerName The owner Author for this project * @param ownerId User ID of Project owner * @param name Name of plugin - * @param slug URL slug - * @param recommendedVersionId The ID of this project's recommended version * @param topicId ID of forum topic * @param postId ID of forum topic post ID - * @param isTopicDirty Whether this project's forum topic needs to be updated * @param visibility Whether this project is visible to the default user * @param notes JSON notes */ @@ -54,8 +49,6 @@ case class Project( ownerName: String, ownerId: DbRef[User], name: String, - slug: String, - recommendedVersionId: Option[DbRef[Version]] = None, category: Category = Category.Undefined, description: Option[String], topicId: Option[Int] = None, @@ -67,6 +60,8 @@ case class Project( with Describable with Visitable { + val slug: String = StringUtils.slugify(name) + def namespace: ProjectNamespace = ProjectNamespace(ownerName, slug) /** @@ -114,16 +109,6 @@ object Project extends DefaultModelCompanion[Project, ProjectTable](TableQuery[P implicit val hasScope: HasScope[Model[Project]] = HasScope.projectScope(_.id) - private def queryRoleForTrust(projectId: Rep[DbRef[Project]], userId: Rep[DbRef[User]]) = { - val q = for { - m <- TableQuery[ProjectMembersTable] if m.projectId === projectId && m.userId === userId - r <- TableQuery[ProjectRoleTable] if m.userId === r.userId && r.projectId === projectId - } yield r.roleType - q.to[Set] - } - - lazy val roleForTrustQuery = lifted.Compiled(queryRoleForTrust _) - implicit def projectHideable[F[_]]( implicit service: ModelService[F], F: Monad[F], @@ -201,13 +186,13 @@ object Project extends DefaultModelCompanion[Project, ProjectTable](TableQuery[P .now(User) .get(newOwner) .getOrElseF(F.raiseError(new Exception("Could not find user to transfer owner to"))) - t2 <- (this.memberships.getRoles(m)(oldOwner), this.memberships.getRoles(m)(newOwner)).parTupled + t2 <- (this.memberships.getMembership(m)(oldOwner), this.memberships.getMembership(m)(newOwner)).parTupled (ownerRoles, userRoles) = t2 setOwner <- setOwner(m)(newOwnerUser) _ <- ownerRoles .filter(_.role == Role.ProjectOwner) .toVector - .parTraverse(role => service.update(role)(_.copy(role = Role.ProjectDeveloper))) + .parTraverse(role => service.update(role)(_.copy(role = Role.ProjectAdmin))) _ <- userRoles.toVector.parTraverse(role => service.update(role)(_.copy(role = Role.ProjectOwner))) } yield setOwner } @@ -252,16 +237,6 @@ object Project extends DefaultModelCompanion[Project, ProjectTable](TableQuery[P Project ).applyChild(self.id) - /** - * Returns this Project's recommended version. - * - * @return Recommended version - */ - def recommendedVersion[QOptRet, SRet[_]]( - view: ModelView[QOptRet, SRet, VersionTable, Model[Version]] - ): Option[QOptRet] = - self.recommendedVersionId.map(versions(view).get) - /** * Sets the "starred" state of this Project for the specified User. * @@ -300,14 +275,6 @@ object Project extends DefaultModelCompanion[Project, ProjectTable](TableQuery[P service.insert(Flag(self.id, user.id, reason, comment)) } - /** - * Returns the Channels in this Project. - * - * @return Channels in project - */ - def channels[V[_, _]: QueryView](view: V[ChannelTable, Model[Channel]]): V[ChannelTable, Model[Channel]] = - view.filterView(_.projectId === self.id.value) - /** * Returns all versions in this project. * diff --git a/models/src/main/scala/ore/models/project/VersionTag.scala b/models/src/main/scala/ore/models/project/TagColor.scala similarity index 75% rename from models/src/main/scala/ore/models/project/VersionTag.scala rename to models/src/main/scala/ore/models/project/TagColor.scala index be924d686..c6d0244a1 100644 --- a/models/src/main/scala/ore/models/project/VersionTag.scala +++ b/models/src/main/scala/ore/models/project/TagColor.scala @@ -1,39 +1,16 @@ package ore.models.project -import java.time.OffsetDateTime - import scala.collection.immutable -import ore.db._ -import ore.db.impl.ModelCompanionPartial -import ore.db.impl.common.Named -import ore.db.impl.schema.VersionTagTable - import enumeratum.values._ -import slick.lifted.TableQuery - -case class VersionTag( - versionId: DbRef[Version], - name: String, - data: Option[String], - color: TagColor -) extends Named -object VersionTag extends ModelCompanionPartial[VersionTag, VersionTagTable](TableQuery[VersionTagTable]) { - - override def asDbModel( - model: VersionTag, - id: ObjId[VersionTag], - time: ObjOffsetDateTime - ): Model[VersionTag] = Model(id, ObjOffsetDateTime(OffsetDateTime.MIN), model) - - implicit val query: ModelQuery[VersionTag] = ModelQuery.from(this) -} sealed abstract class TagColor(val value: Int, val background: String, val foreground: String) extends IntEnumEntry object TagColor extends IntEnum[TagColor] { val values: immutable.IndexedSeq[TagColor] = findValues + case object Undefined extends TagColor(0, "#000000", "#FFFFFF") + // Tag colors case object Sponge extends TagColor(1, "#F7Cf0D", "#333333") case object Forge extends TagColor(2, "#dfa86a", "#FFFFFF") diff --git a/models/src/main/scala/ore/models/project/Version.scala b/models/src/main/scala/ore/models/project/Version.scala index be20821f7..6cc2d8d63 100644 --- a/models/src/main/scala/ore/models/project/Version.scala +++ b/models/src/main/scala/ore/models/project/Version.scala @@ -1,7 +1,5 @@ package ore.models.project -import scala.language.higherKinds - import java.time.OffsetDateTime import ore.data.project.Dependency @@ -12,31 +10,32 @@ import ore.db.impl.common.{Describable, Hideable} import ore.db.impl.schema._ import ore.db.{DbRef, Model, ModelQuery, ModelService} import ore.models.admin.{Review, VersionVisibilityChange} -import ore.models.statistic.VersionDownload import ore.models.user.User import ore.syntax._ import ore.util.FileUtils import cats.data.OptionT import cats.syntax.all._ -import cats.{Monad, MonadError, Parallel} +import cats.{Monad, Parallel} +import enumeratum.values._ +import io.circe._ +import io.circe.syntax._ import slick.lifted.TableQuery /** * Represents a single version of a Project. * * @param versionString Version string - * @param dependencyIds List of plugin dependencies with the plugin ID and - * version separated by a ':' + * @param dependencyIds List of plugin dependencies with the plugin ID + * @param dependencyVersions List of plugin dependencies with the plugin version * @param description User description of version * @param projectId ID of project this version belongs to - * @param channelId ID of channel this version belongs to */ case class Version( projectId: DbRef[Project], versionString: String, dependencyIds: List[String], - channelId: DbRef[Channel], + dependencyVersions: List[Option[String]], fileSize: Long, hash: String, authorId: Option[DbRef[User]], @@ -47,7 +46,8 @@ case class Version( visibility: Visibility = Visibility.Public, fileName: String, createForumPost: Boolean = true, - postId: Option[Int] = None + postId: Option[Int] = None, + tags: Version.VersionTags ) extends Describable { //TODO: Check this in some way @@ -60,17 +60,6 @@ case class Version( */ def name: String = this.versionString - /** - * Returns the channel this version belongs to. - * - * @return Channel - */ - def channel[F[_]: ModelService](implicit F: MonadError[F, Throwable]): F[Model[Channel]] = - ModelView - .now(Channel) - .get(this.channelId) - .getOrElseF(F.raiseError(new NoSuchElementException("None of Option"))) - /** * Returns the base URL for this Version. * @@ -78,9 +67,6 @@ case class Version( */ def url(project: Project): String = project.url + "/versions/" + this.versionString - def author[QOptRet, SRet[_]](view: ModelView[QOptRet, SRet, VersionTagTable, Model[VersionTag]]): Option[QOptRet] = - this.authorId.map(view.get) - def reviewer[QOptRet, SRet[_]](view: ModelView[QOptRet, SRet, UserTable, Model[User]]): Option[QOptRet] = this.reviewerId.map(view.get) @@ -90,10 +76,7 @@ case class Version( * @return Plugin dependencies */ def dependencies: List[Dependency] = - for (depend <- this.dependencyIds) yield { - val data = depend.split(":") - Dependency(data(0), data.lift(1)) - } + dependencyIds.zip(dependencyVersions).map(Dependency.tupled) /** * Returns true if this version has a dependency on the specified plugin ID. @@ -101,7 +84,7 @@ case class Version( * @param pluginId Id to check for * @return True if has dependency on ID */ - def hasDependency(pluginId: String): Boolean = this.dependencies.exists(_.pluginId == pluginId) + def hasDependency(pluginId: String): Boolean = this.dependencyIds.contains(pluginId) /** * Returns a human readable file size for this Version. @@ -116,6 +99,55 @@ case class Version( object Version extends DefaultModelCompanion[Version, VersionTable](TableQuery[VersionTable]) { + case class VersionTags( + usesMixin: Boolean, + stability: Stability, + releaseType: Option[ReleaseType], + channelName: Option[String] = None, + channelColor: Option[TagColor] = None + ) + + sealed abstract class Stability(val value: String) extends StringEnumEntry + object Stability extends StringEnum[Stability] { + override def values: IndexedSeq[Stability] = findValues + + case object Recommended extends Stability("recommended") + case object Stable extends Stability("stable") + case object Beta extends Stability("beta") + case object Alpha extends Stability("alpha") + case object Bleeding extends Stability("bleeding") + case object Unsupported extends Stability("unsupported") + case object Broken extends Stability("broken") + + implicit val codec: Codec[Stability] = Codec.from( + (c: HCursor) => + c.as[String] + .flatMap { str => + withValueOpt(str).toRight(io.circe.DecodingFailure.apply(s"$str is not a valid stability", c.history)) + }, + (a: Stability) => a.value.asJson + ) + } + + sealed abstract class ReleaseType(val value: String) extends StringEnumEntry + object ReleaseType extends StringEnum[ReleaseType] { + override def values: IndexedSeq[ReleaseType] = findValues + + case object MajorUpdate extends ReleaseType("major_update") + case object MinorUpdate extends ReleaseType("minor_update") + case object Patches extends ReleaseType("patches") + case object Hotfix extends ReleaseType("hotfix") + + implicit val codec: Codec[ReleaseType] = Codec.from( + (c: HCursor) => + c.as[String] + .flatMap { str => + withValueOpt(str).toRight(io.circe.DecodingFailure.apply(s"$str is not a valid release type", c.history)) + }, + (a: ReleaseType) => a.value.asJson + ) + } + implicit val query: ModelQuery[Version] = ModelQuery.from(this) implicit val isProjectOwned: ProjectOwned[Version] = (a: Version) => a.projectId @@ -173,11 +205,6 @@ object Version extends DefaultModelCompanion[Version, VersionTable](TableQuery[V implicit class VersionModelOps(private val self: Model[Version]) extends AnyVal { - def tags[V[_, _]: QueryView]( - view: V[VersionTagTable, Model[VersionTag]] - ): V[VersionTagTable, Model[VersionTag]] = - view.filterView(_.versionId === self.id.value) - def reviewEntries[V[_, _]: QueryView](view: V[ReviewTable, Model[Review]]): V[ReviewTable, Model[Review]] = view.filterView(_.versionId === self.id.value) diff --git a/models/src/main/scala/ore/models/project/VersionPlatform.scala b/models/src/main/scala/ore/models/project/VersionPlatform.scala new file mode 100644 index 000000000..272036e1c --- /dev/null +++ b/models/src/main/scala/ore/models/project/VersionPlatform.scala @@ -0,0 +1,19 @@ +package ore.models.project + +import ore.db.impl.DefaultModelCompanion +import ore.db.impl.schema.VersionPlatformTable +import ore.db.{DbRef, ModelQuery} + +import slick.lifted.TableQuery + +case class VersionPlatform( + versionId: DbRef[Version], + platform: String, + platformVersion: Option[String], + platformCoarseVersion: Option[String] +) +object VersionPlatform + extends DefaultModelCompanion[VersionPlatform, VersionPlatformTable](TableQuery[VersionPlatformTable]) { + + implicit val query: ModelQuery[VersionPlatform] = ModelQuery.from(this) +} diff --git a/models/src/main/scala/ore/models/project/Visibility.scala b/models/src/main/scala/ore/models/project/Visibility.scala index 6a8e37e30..aafe9a241 100644 --- a/models/src/main/scala/ore/models/project/Visibility.scala +++ b/models/src/main/scala/ore/models/project/Visibility.scala @@ -10,18 +10,17 @@ import enumeratum.values._ sealed abstract class Visibility( val value: Int, val nameKey: String, - val showModal: Boolean, - val cssClass: String + val showModal: Boolean ) extends IntEnumEntry object Visibility extends IntEnum[Visibility] { val values: immutable.IndexedSeq[Visibility] = findValues - case object Public extends Visibility(1, "public", showModal = false, "") - case object New extends Visibility(2, "new", showModal = false, "project-new") - case object NeedsChanges extends Visibility(3, "needsChanges", showModal = true, "striped project-needsChanges") - case object NeedsApproval extends Visibility(4, "needsApproval", showModal = false, "striped project-needsChanges") - case object SoftDelete extends Visibility(5, "softDelete", showModal = true, "striped project-hidden") + case object Public extends Visibility(1, "public", showModal = false) + case object New extends Visibility(2, "new", showModal = false) + case object NeedsChanges extends Visibility(3, "needsChanges", showModal = true) + case object NeedsApproval extends Visibility(4, "needsApproval", showModal = false) + case object SoftDelete extends Visibility(5, "softDelete", showModal = true) def isPublic(visibility: Visibility): Boolean = visibility == Public diff --git a/models/src/main/scala/ore/models/user/Session.scala b/models/src/main/scala/ore/models/user/Session.scala index 9552c434b..27eed145f 100644 --- a/models/src/main/scala/ore/models/user/Session.scala +++ b/models/src/main/scala/ore/models/user/Session.scala @@ -2,10 +2,10 @@ package ore.models.user import java.time.OffsetDateTime -import ore.db.{DbRef, ModelQuery} import ore.db.impl.DefaultModelCompanion import ore.db.impl.common.Expirable import ore.db.impl.schema.SessionTable +import ore.db.{DbRef, ModelQuery} import slick.lifted.TableQuery diff --git a/models/src/main/scala/ore/models/user/User.scala b/models/src/main/scala/ore/models/user/User.scala index 0dcd5dffd..05c02e66e 100644 --- a/models/src/main/scala/ore/models/user/User.scala +++ b/models/src/main/scala/ore/models/user/User.scala @@ -14,7 +14,7 @@ import ore.db.impl.query.UserQueries import ore.db.impl.schema._ import ore.db.impl.{ModelCompanionPartial, OrePostgresDriver} import ore.models.organization.Organization -import ore.models.project.{Flag, Project, Visibility} +import ore.models.project.{Flag, Project} import ore.models.user.role.{DbRole, OrganizationUserRole, ProjectUserRole} import ore.permission._ import ore.permission.scope._ @@ -41,7 +41,6 @@ case class User( tagline: Option[String] = None, joinDate: Option[OffsetDateTime] = None, readPrompts: List[Prompt] = Nil, - isLocked: Boolean = false, lang: Option[Locale] = None ) extends Named @@ -56,15 +55,6 @@ object User extends ModelCompanionPartial[User, UserTable](TableQuery[UserTable] implicit val query: ModelQuery[User] = ModelQuery.from(this) - implicit val assocMembersQuery: AssociationQuery[ProjectMembersTable, User, Project] = - AssociationQuery.from[ProjectMembersTable, User, Project](TableQuery[ProjectMembersTable])(_.userId, _.projectId) - - implicit val assocOrgMembersQuery: AssociationQuery[OrganizationMembersTable, User, Organization] = - AssociationQuery.from[OrganizationMembersTable, User, Organization](TableQuery[OrganizationMembersTable])( - _.userId, - _.organizationId - ) - implicit val assocStarsQuery: AssociationQuery[ProjectStarsTable, User, Project] = AssociationQuery.from[ProjectStarsTable, User, Project](TableQuery[ProjectStarsTable])(_.userId, _.projectId) @@ -84,17 +74,6 @@ object User extends ModelCompanionPartial[User, UserTable](TableQuery[UserTable] ): ParentAssociationAccess[UserGlobalRolesTable, User, DbRole, UserTable, DbRoleTable, F] = new ModelAssociationAccessImpl(OrePostgresDriver)(User, DbRole).applyParent(self.id) - /** - * Returns the [[Organization]]s that this User belongs to. - * - * @return Organizations user belongs to - */ - def organizations[F[_]]( - implicit service: ModelService[F], - F: Functor[F] - ): ParentAssociationAccess[OrganizationMembersTable, User, Organization, UserTable, OrganizationTable, F] = - new ModelAssociationAccessImpl(OrePostgresDriver)(User, Organization).applyParent(self.id) - /** * Returns the [[Project]]s that this User is watching. * @@ -148,23 +127,6 @@ object User extends ModelCompanionPartial[User, UserTable](TableQuery[UserTable] service.runDbCon(conIO).map(_ ++ alwaysHasPermissions) } - /** - * Returns the Projects that this User has starred. - * - * @return Projects user has starred - */ - def starred[F[_]](implicit service: ModelService[F]): F[Seq[Model[Project]]] = { - val filter = Visibility.isPublicFilter[ProjectTable] - - val baseQuery = for { - assoc <- TableQuery[ProjectStarsTable] if assoc.userId === self.id.value - project <- TableQuery[ProjectTable] if assoc.projectId === project.id - if filter(project) - } yield project - - service.runDBIO(baseQuery.sortBy(_.name).result) - } - /** * Returns all [[Project]]s owned by this user. * diff --git a/models/src/main/scala/ore/models/user/role/UserRoleModel.scala b/models/src/main/scala/ore/models/user/role/UserRoleModel.scala index e0c8751dc..d8e16bc40 100644 --- a/models/src/main/scala/ore/models/user/role/UserRoleModel.scala +++ b/models/src/main/scala/ore/models/user/role/UserRoleModel.scala @@ -3,8 +3,9 @@ package ore.models.user.role import scala.language.higherKinds import ore.db.impl.common.Visitable -import ore.db.{Model, ModelService} +import ore.db.{DbRef, Model, ModelService} import ore.models.organization.Organization +import ore.models.user.User import ore.permission.role.Role import cats.MonadError @@ -15,6 +16,8 @@ import cats.MonadError */ abstract class UserRoleModel[Self] { + def userId: DbRef[User] + /** * Type of Role */ diff --git a/models/src/main/scala/ore/permission/NamedPermission.scala b/models/src/main/scala/ore/permission/NamedPermission.scala index cb075731e..00b7f2c79 100644 --- a/models/src/main/scala/ore/permission/NamedPermission.scala +++ b/models/src/main/scala/ore/permission/NamedPermission.scala @@ -37,10 +37,10 @@ object NamedPermission extends Enum[NamedPermission] { case object ViewStats extends NamedPermission(Permission.ViewStats) case object ViewLogs extends NamedPermission(Permission.ViewLogs) - case object ManualValueChanges extends NamedPermission(Permission.ManualValueChanges) - case object HardDeleteProject extends NamedPermission(Permission.HardDeleteProject) - case object HardDeleteVersion extends NamedPermission(Permission.HardDeleteVersion) - case object EditAllUserSettings extends NamedPermission(Permission.EditAllUserSettings) + case object ManualValueChanges extends NamedPermission(Permission.ManualValueChanges) + case object HardDeleteProject extends NamedPermission(Permission.HardDeleteProject) + case object HardDeleteVersion extends NamedPermission(Permission.HardDeleteVersion) + case object EditAdminSettings extends NamedPermission(Permission.EditAdminSettings) override def values: immutable.IndexedSeq[NamedPermission] = findValues diff --git a/models/src/main/scala/ore/permission/package.scala b/models/src/main/scala/ore/permission/package.scala index db9b47af8..722a8c26e 100644 --- a/models/src/main/scala/ore/permission/package.scala +++ b/models/src/main/scala/ore/permission/package.scala @@ -68,10 +68,10 @@ package object permission { val ViewStats = Permission(1L << 34) val ViewLogs = Permission(1L << 35) - val ManualValueChanges = Permission(1L << 40) - val HardDeleteProject = Permission(1L << 41) - val HardDeleteVersion = Permission(1L << 42) - val EditAllUserSettings = Permission(1L << 43) + val ManualValueChanges = Permission(1L << 40) + val HardDeleteProject = Permission(1L << 41) + val HardDeleteVersion = Permission(1L << 42) + val EditAdminSettings = Permission(1L << 43) } implicit class PermissionSyntax(private val permission: Permission) extends AnyVal { diff --git a/models/src/main/scala/ore/permission/role/roles.scala b/models/src/main/scala/ore/permission/role/roles.scala index f8d6fb599..165eebd0b 100644 --- a/models/src/main/scala/ore/permission/role/roles.scala +++ b/models/src/main/scala/ore/permission/role/roles.scala @@ -111,15 +111,28 @@ object Role extends StringEnum[Role] { RoleCategory.Project, Perm( Perm.IsProjectOwner, - Perm.EditApiKeys, Perm.DeleteProject, - Perm.DeleteVersion, - ProjectDeveloper.permissions + ProjectAdmin.permissions ), "Owner", Transparent, isAssignable = false ) + object ProjectAdmin + extends Role( + "Project_Admin", + 29, + RoleCategory.Project, + Perm( + Perm.EditProjectSettings, + Perm.ManageProjectMembers, + Perm.EditApiKeys, + Perm.DeleteVersion, + ProjectDeveloper.permissions + ), + "Admin", + Transparent + ) object ProjectDeveloper extends Role( "Project_Developer", diff --git a/models/src/main/scala/ore/permission/scope/scope.scala b/models/src/main/scala/ore/permission/scope/scope.scala index b2370e048..b5efe0bda 100644 --- a/models/src/main/scala/ore/permission/scope/scope.scala +++ b/models/src/main/scala/ore/permission/scope/scope.scala @@ -5,12 +5,7 @@ import ore.models.organization.Organization import ore.models.project.Project sealed trait Scope -object Scope extends LowPriorityScope { - implicit val globalScopeHasScope: HasScope[GlobalScope.type] = (a: GlobalScope.type) => a - implicit val projectScopeHasScope: HasScope[ProjectScope] = (a: ProjectScope) => a - implicit val organizationScopeHasScope: HasScope[OrganizationScope] = (a: OrganizationScope) => a -} -trait LowPriorityScope { +object Scope { implicit val scopeHasScope: HasScope[Scope] = (a: Scope) => a } diff --git a/models/src/main/scala/ore/util/StringUtils.scala b/models/src/main/scala/ore/util/StringUtils.scala index f51255a15..83e2f7f72 100644 --- a/models/src/main/scala/ore/util/StringUtils.scala +++ b/models/src/main/scala/ore/util/StringUtils.scala @@ -1,8 +1,7 @@ package ore.util -import java.nio.file.{Files, Path} import java.security.MessageDigest -import java.text.MessageFormat +import java.util.Locale import ore.db.impl.OrePostgresDriver.api._ @@ -11,13 +10,18 @@ import ore.db.impl.OrePostgresDriver.api._ */ object StringUtils { + private val replaceRegex = """[^a-z\-_.0-9]""".r.unanchored + /** * Returns a URL readable string from the specified string. * * @param str String to create slug for * @return Slug of string */ - def slugify(str: String): String = compact(str).replace(' ', '-') + def slugify(str: String): String = { + val replaced = replaceRegex.replaceAllIn(compact(str).toLowerCase(Locale.ROOT).replace(' ', '-'), "") + replaced.substring(0, Math.min(32, replaced.length)) + } /** * Returns the specified String with all consecutive spaces removed. diff --git a/ore/app/OreApplicationLoader.scala b/ore/app/OreApplicationLoader.scala index 3f587200b..01c8cf7cb 100644 --- a/ore/app/OreApplicationLoader.scala +++ b/ore/app/OreApplicationLoader.scala @@ -19,18 +19,17 @@ import play.api.routing.Router import play.api.{ ApplicationLoader, BuiltInComponentsFromContext, - Configuration, LoggerConfigurator, OptionalSourceMapper, Application => PlayApplication } import play.filters.HttpFiltersComponents +import play.filters.cors.{CORSConfigProvider, CORSFilterProvider} import play.filters.csp.{CSPConfig, CSPFilter, DefaultCSPProcessor, DefaultCSPResultProcessor} import play.filters.gzip.{GzipFilter, GzipFilterConfig} import controllers._ -import controllers.apiv2.ApiV2Controller -import controllers.project.{Channels, Pages, Projects, Versions} +import controllers.project.{Projects, Versions} import controllers.sugar.Bakery import db.impl.{DbUpdateTask, OreEvolutionsReader} import db.impl.access.{OrganizationBase, ProjectBase, UserBase} @@ -56,13 +55,12 @@ import akka.actor.ActorSystem import cats.arrow.FunctionK import cats.effect.{ContextShift, Resource} import cats.tagless.syntax.all._ -import cats.{Defer, ~>} +import cats.~> import com.softwaremill.macwire._ import com.typesafe.scalalogging.Logger import doobie.util.transactor.Strategy import doobie.{ExecutionContexts, KleisliInterpreter, Transactor} import pureconfig.ConfigSource -import pureconfig.generic.auto._ import slick.basic.{BasicProfile, DatabaseConfig} import slick.jdbc.{JdbcDataSource, JdbcProfile} import zio.blocking.Blocking @@ -100,7 +98,7 @@ class OreComponents(context: ApplicationLoader.Context) val logger = Logger("Bootstrap") override lazy val httpFilters: Seq[EssentialFilter] = { - val filters = super.httpFilters ++ enabledFilters + val filters = enabledFilters ++ super.httpFilters val enabledFiltersConfig = configuration.get[Seq[String]]("play.filters.enabled") val enabledFiltersCode = filters.map(_.getClass.getName) @@ -114,11 +112,15 @@ class OreComponents(context: ApplicationLoader.Context) } lazy val enabledFilters: Seq[EssentialFilter] = { + val baseFilters = Seq( new CSPFilter(new DefaultCSPResultProcessor(new DefaultCSPProcessor(CSPConfig.fromConfiguration(configuration)))) ) - val devFilters = Seq(new GzipFilter(GzipFilterConfig.fromConfiguration(configuration))) + val devFilters = Seq( + new GzipFilter(GzipFilterConfig.fromConfiguration(configuration)), + new CORSFilterProvider(configuration, httpErrorHandler, new CORSConfigProvider(configuration).get).get + ) val filterSeq = Seq( true -> baseFilters, @@ -256,26 +258,36 @@ class OreComponents(context: ApplicationLoader.Context) lazy val statusZ: StatusZ = wire[StatusZ] lazy val fakeUser: FakeUser = wire[FakeUser] - lazy val applicationController: Application = wire[Application] - lazy val apiV1Controller: ApiV1Controller = wire[ApiV1Controller] - lazy val apiV2Controller: ApiV2Controller = wire[ApiV2Controller] - lazy val versions: Versions = wire[Versions] - lazy val users: Users = wire[Users] - lazy val projects: Projects = wire[Projects] - lazy val pages: Pages = wire[Pages] - lazy val organizations: Organizations = wire[Organizations] - lazy val channels: Channels = wire[Channels] - lazy val reviews: Reviews = wire[Reviews] - lazy val applicationControllerProvider: Provider[Application] = () => applicationController - lazy val apiV1ControllerProvider: Provider[ApiV1Controller] = () => apiV1Controller - lazy val apiV2ControllerProvider: Provider[ApiV2Controller] = () => apiV2Controller - lazy val versionsProvider: Provider[Versions] = () => versions - lazy val usersProvider: Provider[Users] = () => users - lazy val projectsProvider: Provider[Projects] = () => projects - lazy val pagesProvider: Provider[Pages] = () => pages - lazy val organizationsProvider: Provider[Organizations] = () => organizations - lazy val channelsProvider: Provider[Channels] = () => channels - lazy val reviewsProvider: Provider[Reviews] = () => reviews + lazy val applicationController: Application = wire[Application] + lazy val apiV1Controller: ApiV1Controller = wire[ApiV1Controller] + lazy val apiV2Authentication: apiv2.Authentication = wire[apiv2.Authentication] + lazy val apiV2Keys: apiv2.Keys = wire[apiv2.Keys] + lazy val apiV2Permissions: apiv2.Permissions = wire[apiv2.Permissions] + lazy val apiV2Projects: apiv2.Projects = wire[apiv2.Projects] + lazy val apiV2Users: apiv2.Users = wire[apiv2.Users] + lazy val apiV2Versions: apiv2.Versions = wire[apiv2.Versions] + lazy val apiV2Pages: apiv2.Pages = wire[apiv2.Pages] + lazy val apiV2Organizations: apiv2.Organizations = wire[apiv2.Organizations] + lazy val versions: Versions = wire[Versions] + lazy val users: Users = wire[Users] + lazy val projects: Projects = wire[Projects] + lazy val organizations: Organizations = wire[Organizations] + lazy val reviews: Reviews = wire[Reviews] + lazy val applicationControllerProvider: Provider[Application] = () => applicationController + lazy val apiV1ControllerProvider: Provider[ApiV1Controller] = () => apiV1Controller + lazy val apiV2AuthenticationProvider: Provider[apiv2.Authentication] = () => apiV2Authentication + lazy val apiV2KeysProvider: Provider[apiv2.Keys] = () => apiV2Keys + lazy val apiV2PermissionsProvider: Provider[apiv2.Permissions] = () => apiV2Permissions + lazy val apiV2ProjectsProvider: Provider[apiv2.Projects] = () => apiV2Projects + lazy val apiV2UsersProvider: Provider[apiv2.Users] = () => apiV2Users + lazy val apiV2VersionsProvider: Provider[apiv2.Versions] = () => apiV2Versions + lazy val apiV2PagesProvider: Provider[apiv2.Pages] = () => apiV2Pages + lazy val apiV2OrganizationsProvider: Provider[apiv2.Organizations] = () => apiV2Organizations + lazy val versionsProvider: Provider[Versions] = () => versions + lazy val usersProvider: Provider[Users] = () => users + lazy val projectsProvider: Provider[Projects] = () => projects + lazy val organizationsProvider: Provider[Organizations] = () => organizations + lazy val reviewsProvider: Provider[Reviews] = () => reviews def runWhenEvolutionsDone(action: UIO[Unit]): Unit = { val isDone = ZIO.effectTotal(applicationEvolutions.upToDate) diff --git a/ore/app/controllers/ApiV1Controller.scala b/ore/app/controllers/ApiV1Controller.scala index ac8d62b84..9a858a7d0 100644 --- a/ore/app/controllers/ApiV1Controller.scala +++ b/ore/app/controllers/ApiV1Controller.scala @@ -17,11 +17,11 @@ import ore.models.api.ProjectApiKey import ore.models.organization.Organization import ore.models.project.factory.ProjectFactory import ore.models.project.io.PluginUpload -import ore.models.project.{Channel, Page, Project, Version} +import ore.models.project.{Page, Project, TagColor, Version} import ore.models.user.{LoggedActionProject, LoggedActionType, User} import ore.permission.Permission import ore.permission.role.Role -import ore.rest.{OreRestfulApiV1, OreWrites} +import ore.rest.{FakeChannel, OreRestfulApiV1, OreWrites} import _root_.util.syntax._ import _root_.util.{StatusZ, UserActionLogger} @@ -49,7 +49,7 @@ final class ApiV1Controller( def AuthedProjectActionById( pluginId: String ): ActionBuilder[AuthedProjectRequest, AnyContent] = - UserLock(ShowHome).andThen(authedProjectActionById(pluginId)) + Authenticated.andThen(authedProjectActionById(pluginId)) private val Logger = scalalogging.Logger("SSO") @@ -80,6 +80,17 @@ final class ApiV1Controller( this.api.getProject(pluginId).map(ApiResult) } + def getKey(pluginId: String): Action[AnyContent] = + Action.andThen(AuthedProjectActionById(pluginId)).andThen(ProjectPermissionAction(Permission.EditApiKeys)).asyncF { + implicit request => + ModelView + .now(ProjectApiKey) + .find(_.projectId === request.project.id.value) + .value + .someOrFail(NotFound) + .map(key => Ok(Json.obj("id" -> key.id.value, "key" -> key.value))) + } + def createKey(pluginId: String): Action[AnyContent] = Action.andThen(AuthedProjectActionById(pluginId)).andThen(ProjectPermissionAction(Permission.EditApiKeys)).asyncF { implicit request => @@ -116,22 +127,21 @@ final class ApiV1Controller( def revokeKey(pluginId: String): Action[AnyContent] = AuthedProjectActionById(pluginId).andThen(ProjectPermissionAction(Permission.EditApiKeys)).asyncF { implicit request => - val res = for { - optKey <- forms.ProjectApiKeyRevoke.bindOptionT[UIO] - key <- optKey - if key.projectId == request.data.project.id.value - _ <- OptionT.liftF(service.delete(key)) - _ <- OptionT.liftF( - UserActionLogger.log( - request.request, - LoggedActionType.ProjectSettingsChanged, - request.data.project.id, - s"${request.user.name} removed an ApiKey", - "" - )(LoggedActionProject.apply) - ) + for { + optKey <- forms.ProjectApiKeyRevoke + .bindZIO(hasErrors => BadRequest(Json.obj("errors" -> hasErrors.errorsAsJson))) + key <- optKey.toZIO.orElseFail(BadRequest(Json.obj("error" -> "Key not found"))) + _ <- if (key.projectId == request.data.project.id.value) ZIO.unit + else ZIO.fail(BadRequest(Json.obj("error" -> "Wrong key"))) + _ <- service.delete(key) + _ <- UserActionLogger.log( + request.request, + LoggedActionType.ProjectSettingsChanged, + request.data.project.id, + s"${request.user.name} removed an ApiKey", + "" + )(LoggedActionProject.apply) } yield Ok - res.getOrElse(BadRequest) } /** @@ -174,74 +184,75 @@ final class ApiV1Controller( forms.VersionDeploy .bindEitherT[ZIO[Blocking, Nothing, *]](hasErrors => BadRequest(Json.obj("errors" -> hasErrors.errorsAsJson))) .flatMap { formData => - OptionT(formData.channel.value: ZIO[Blocking, Nothing, Option[Model[Channel]]]) - .toRight(BadRequest(Json.obj("errors" -> "Invalid channel"))) - .map(formData -> _) - } - .flatMap { - case (formData, formChannel) => - val apiKeyTable = TableQuery[ProjectApiKeyTable] - def queryApiKey(key: String, pId: DbRef[Project]) = { - val query = for { - k <- apiKeyTable if k.value === key && k.projectId === pId - } yield { - k.id - } - query.exists + val stability = formData.channel + .map(_.toLowerCase) + .collect { + case "release" => Version.Stability.Stable + case "beta" => Version.Stability.Beta + case "prerelease" => Version.Stability.Beta + case "alpha" => Version.Stability.Alpha + case "unstable" => Version.Stability.Alpha + case "bleeding" => Version.Stability.Bleeding + case "snapshot" => Version.Stability.Bleeding } + .getOrElse(Version.Stability.Stable) + + val apiKeyTable = TableQuery[ProjectApiKeyTable] + def queryApiKey(key: String, pId: DbRef[Project]) = { + val query = for { + k <- apiKeyTable if k.value === key && k.projectId === pId + } yield { + k.id + } + query.exists + } - val query = Query.apply( - ( - queryApiKey(formData.apiKey, project.id), - project.versions(ModelView.later(Version)).exists(_.versionString === name) - ) + val query = Query.apply( + ( + queryApiKey(formData.apiKey, project.id), + project.versions(ModelView.later(Version)).exists(_.versionString === name) ) + ) - EitherT - .liftF[ZIO[Blocking, Nothing, *], Result, (Boolean, Boolean)](service.runDBIO(query.result.head)) - .ensure(Unauthorized(error("apiKey", "api.deploy.invalidKey")))(apiKeyExists => apiKeyExists._1) - .ensure(BadRequest(error("versionName", "api.deploy.versionExists")))(nameExists => !nameExists._2) - .semiflatMap(_ => project.user[Task].orDie) - .semiflatMap(user => - user.toMaybeOrganization(ModelView.now(Organization)).semiflatMap(_.user[Task].orDie).getOrElse(user) - ) - .flatMap { owner => - val pluginUpload = this.factory - .getUploadError(owner) - .map(err => BadRequest(error("user", err))) - .toLeft(PluginUpload.bindFromRequest()) - .flatMap(_.toRight(BadRequest(error("files", "error.noFile")))) - - EitherT.fromEither[ZIO[Blocking, Nothing, *]](pluginUpload).flatMap { data => - EitherT( - this.factory - .processSubsequentPluginUpload(data, owner, project) - .either - ).leftMap(err => BadRequest(error("upload", err))) - } - } - .map { pendingVersion => - pendingVersion.copy( - createForumPost = formData.createForumPost, - channelName = formChannel.name, - description = formData.changelog - ) + EitherT + .liftF[ZIO[Blocking, Nothing, *], Result, (Boolean, Boolean)](service.runDBIO(query.result.head)) + .ensure(Unauthorized(error("apiKey", "api.deploy.invalidKey")))(apiKeyExists => apiKeyExists._1) + .ensure(BadRequest(error("versionName", "api.deploy.versionExists")))(nameExists => !nameExists._2) + .semiflatMap(_ => project.user[Task].orDie) + .semiflatMap(user => + user.toMaybeOrganization(ModelView.now(Organization)).semiflatMap(_.user[Task].orDie).getOrElse(user) + ) + .flatMap { owner => + val pluginUpload = PluginUpload.bindFromRequest().toRight(BadRequest(error("files", "error.noFile"))) + + EitherT.fromEither[ZIO[Blocking, Nothing, *]](pluginUpload).flatMap { data => + EitherT( + this.factory + .collectErrorsForVersionUpload(data, owner, project) + .either + ).leftMap(err => BadRequest(error("upload", err))) } - .semiflatMap(_.complete(project, factory)) - .semiflatMap { - case (newProject, newVersion, channel, tags) => - val update = - if (formData.recommended) - service.update(project)( - _.copy( - recommendedVersionId = Some(newVersion.id) - ) - ) - else - ZIO.unit - - update.as(Created(api.writeVersion(newVersion, newProject, channel, None, tags))) + } + .flatMap { fileWithData => + EitherT( + factory + .createVersion(project, fileWithData, formData.changelog, formData.createForumPost, stability, None) + .either + ).leftMap { es => + BadRequest(JsArray(es.toList.view.zipWithIndex.map(t => error(t._2.toString, t._1)).toSeq)) } + } + .map { + case (newProject, newVersion, _) => + Created( + api.writeVersion( + newVersion, + newProject, + FakeChannel("Channel", TagColor.Green, isNonReviewed = false), + None + ) + ) + } } .merge } diff --git a/ore/app/controllers/Application.scala b/ore/app/controllers/Application.scala index 6df9dbc2c..b63d39f9a 100644 --- a/ore/app/controllers/Application.scala +++ b/ore/app/controllers/Application.scala @@ -9,7 +9,6 @@ import java.util.Date import java.util.concurrent.TimeUnit import scala.concurrent.Future -import scala.concurrent.duration._ import scala.util.Try import play.api.http.{ContentTypes, HttpErrorHandler, Writeable} @@ -25,13 +24,7 @@ import models.viewhelper.{OrganizationData, UserData} import ore.db._ import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{ - LoggedActionOrganizationTable, - LoggedActionPageTable, - LoggedActionProjectTable, - LoggedActionUserTable, - LoggedActionVersionTable -} +import ore.db.impl.schema._ import ore.markdown.MarkdownRenderer import ore.member.MembershipDossier import ore.models.organization.Organization @@ -40,8 +33,8 @@ import ore.models.user._ import ore.models.user.role._ import ore.permission._ import ore.permission.role.{Role, RoleCategory} -import util.{Sitemap, UserActionLogger} import util.syntax._ +import util.{Sitemap, UserActionLogger} import views.{html => views} import akka.util.{ByteString, Timeout} @@ -65,10 +58,34 @@ final class Application(forms: OreForms, val errorHandler: HttpErrorHandler)( def javascriptRoutes: Action[AnyContent] = Action { implicit request => Ok( JavaScriptReverseRouter("jsRoutes")( - controllers.project.routes.javascript.Projects.show, - controllers.project.routes.javascript.Versions.show, - controllers.project.routes.javascript.Versions.showCreator, - controllers.routes.javascript.Users.showProjects + controllers.project.routes.javascript.Projects.showFlags, + controllers.project.routes.javascript.Projects.showNotes, + controllers.project.routes.javascript.Projects.showStargazers, + controllers.project.routes.javascript.Projects.toggleStarred, + controllers.project.routes.javascript.Projects.showWatchers, + controllers.project.routes.javascript.Projects.setWatching, + controllers.project.routes.javascript.Projects.flag, + controllers.project.routes.javascript.Versions.download, + controllers.routes.javascript.Users.editApiKeys, + controllers.routes.javascript.Users.logIn, + controllers.routes.javascript.Users.signUp, + controllers.routes.javascript.Users.logOut, + controllers.routes.javascript.Users.showAuthors, + controllers.routes.javascript.Users.showStaff, + controllers.routes.javascript.Users.showNotifications, + controllers.routes.javascript.Users.saveTagline, + controllers.routes.javascript.Application.showLog, + controllers.routes.javascript.Application.linkOut, + controllers.routes.javascript.Application.showActivities, + controllers.routes.javascript.Application.userAdmin, + controllers.routes.javascript.Application.swagger, + controllers.routes.javascript.Application.showFlags, + controllers.routes.javascript.Application.showProjectVisibility, + controllers.routes.javascript.Application.showQueue, + controllers.routes.javascript.Application.showStats, + controllers.routes.javascript.Application.showHealth, + controllers.routes.javascript.Reviews.showReviews, + controllers.routes.javascript.Organizations.showCreator ) ).as("text/javascript") } @@ -277,7 +294,7 @@ final class Application(forms: OreForms, val errorHandler: HttpErrorHandler)( } def UserAdminAction: ActionBuilder[AuthRequest, AnyContent] = - Authenticated.andThen(PermissionAction(Permission.EditAllUserSettings)) + Authenticated.andThen(PermissionAction(Permission.EditAdminSettings)) def userAdmin(user: String): Action[AnyContent] = UserAdminAction.asyncF { implicit request => for { @@ -368,7 +385,7 @@ final class Application(forms: OreForms, val errorHandler: HttpErrorHandler)( case "memberRole" => user.toMaybeOrganization(ModelView.now(Organization)).toZIO.mapError(Right.apply).flatMap { orga => updateRoleTable(OrganizationUserRole)( - orgDossier.roles(orga), + orgDossier.memberships(orga), RoleCategory.Organization, Role.OrganizationOwner, transferOrgOwner diff --git a/ore/app/controllers/Organizations.scala b/ore/app/controllers/Organizations.scala index 9f893d7a0..53f467ca2 100644 --- a/ore/app/controllers/Organizations.scala +++ b/ore/app/controllers/Organizations.scala @@ -36,7 +36,7 @@ class Organizations(forms: OreForms)( * * @return Organization creation panel */ - def showCreator(): Action[AnyContent] = UserLock().asyncF { implicit request => + def showCreator(): Action[AnyContent] = Authenticated.asyncF { implicit request => service .runDBIO((request.user.ownedOrganizations(ModelView.later(Organization)).size > this.createLimit).result) .map { limitReached => @@ -54,15 +54,13 @@ class Organizations(forms: OreForms)( * @return Redirect to organization page */ def create(): Action[OrganizationRoleSetBuilder] = - UserLock().asyncF( + Authenticated.asyncF( parse.form(forms.OrganizationCreate, onErrors = FormErrorLocalized(routes.Organizations.showCreator())) ) { implicit request => val user = request.user val failCall = routes.Organizations.showCreator() - if (user.isLocked) { - IO.fail(BadRequest) - } else if (!this.config.ore.orgs.enabled) { + if (!this.config.ore.orgs.enabled) { IO.fail(Redirect(failCall).withError("error.org.disabled")) } else { service @@ -102,7 +100,7 @@ class Organizations(forms: OreForms)( import MembershipDossier._ status match { case STATUS_DECLINE => - role.organization[Task].orDie.flatMap(org => org.memberships.removeRole(org)(role.id)).as(Ok) + role.organization[Task].orDie.flatMap(org => org.memberships.removeMember(org)(role.userId)).as(Ok) case STATUS_ACCEPT => service.update(role)(_.copy(isAccepted = true)).as(Ok) case STATUS_UNACCEPT => service.update(role)(_.copy(isAccepted = false)).as(Ok) case _ => IO.fail(BadRequest) @@ -117,7 +115,7 @@ class Organizations(forms: OreForms)( * @return Redirect to auth or bad request */ def updateAvatar(organization: String): Action[AnyContent] = - AuthedOrganizationAction(organization, requireUnlock = true) + AuthedOrganizationAction(organization) .andThen(OrganizationPermissionAction(Permission.EditOrganizationSettings)) .asyncF { implicit request => implicit val lang: Lang = request.lang @@ -136,7 +134,7 @@ class Organizations(forms: OreForms)( * @return Redirect to Organization page */ def removeMember(organization: String): Action[String] = - AuthedOrganizationAction(organization, requireUnlock = true) + AuthedOrganizationAction(organization) .andThen(OrganizationPermissionAction(Permission.ManageOrganizationMembers)) .asyncF(parse.form(forms.OrganizationMemberRemove)) { implicit request => val res = for { @@ -154,7 +152,7 @@ class Organizations(forms: OreForms)( * @return Redirect to Organization page */ def updateMembers(organization: String): Action[OrganizationMembersUpdate] = - AuthedOrganizationAction(organization, requireUnlock = true) + AuthedOrganizationAction(organization) .andThen(OrganizationPermissionAction(Permission.ManageOrganizationMembers))( parse.form(forms.OrganizationUpdateMembers) ) diff --git a/ore/app/controllers/Reviews.scala b/ore/app/controllers/Reviews.scala index f5d760722..15de78482 100644 --- a/ore/app/controllers/Reviews.scala +++ b/ore/app/controllers/Reviews.scala @@ -9,7 +9,7 @@ import form.OreForms import ore.data.user.notification.NotificationType import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{OrganizationMembersTable, OrganizationRoleTable, OrganizationTable, UserTable} +import ore.db.impl.schema.{OrganizationRoleTable, OrganizationTable, ProjectTable, UserTable} import ore.db.{DbRef, Model} import ore.markdown.MarkdownRenderer import ore.models.admin.{Message, Review} @@ -27,7 +27,6 @@ import io.circe.Json import slick.lifted.{Rep, TableQuery} import zio.interop.catz._ import zio.{UIO, ZIO} -import zio.interop.catz._ /** * Controller for handling Review related actions. @@ -121,10 +120,10 @@ final class Reviews(forms: OreForms)( ): Query[(Rep[DbRef[User]], Rep[Option[Role]]), (DbRef[User], Option[Role]), Seq] = { // Query Orga Members val q1 = for { - org <- TableQuery[OrganizationTable] if org.id === projectId - members <- TableQuery[OrganizationMembersTable] if org.id === members.organizationId - roles <- TableQuery[OrganizationRoleTable] if members.userId === roles.userId // TODO roletype lvl in database? - users <- TableQuery[UserTable] if members.userId === users.id + project <- TableQuery[ProjectTable] if project.id === projectId + org <- TableQuery[OrganizationTable] if org.id === project.ownerId + roles <- TableQuery[OrganizationRoleTable] if roles.organizationId === org.id // TODO roletype lvl in database? + users <- TableQuery[UserTable] if users.id === roles.userId } yield (users.id, roles.roleType.?) // Query version author diff --git a/ore/app/controllers/Users.scala b/ore/app/controllers/Users.scala index 0b931f316..82292a777 100644 --- a/ore/app/controllers/Users.scala +++ b/ore/app/controllers/Users.scala @@ -8,21 +8,20 @@ import play.api.mvc._ import db.impl.access.UserBase.UserOrdering import db.impl.query.UserPagesQueries import form.OreForms -import mail.{EmailFactory, Mailer} import models.viewhelper.{OrganizationData, ScopedOrganizationData, UserData} import ore.auth.URLWithNonce import ore.data.Prompt import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.query.UserQueries -import ore.db.impl.schema.{ApiKeyTable, PageTable, ProjectTable, UserTable, VersionTable} +import ore.db.impl.schema._ import ore.db.{DbRef, Model} import ore.models.user.notification.{InviteFilter, NotificationFilter} import ore.models.user.{FakeUser, _} import ore.permission.Permission import ore.permission.role.Role -import util.{Sitemap, UserActionLogger} import util.syntax._ +import util.{Sitemap, UserActionLogger} import views.{html => views} import cats.syntax.all._ @@ -34,9 +33,7 @@ import zio.{IO, Task, UIO, ZIO} */ class Users( fakeUser: FakeUser, - forms: OreForms, - mailer: Mailer, - emails: EmailFactory + forms: OreForms )( implicit oreComponents: OreControllerComponents, messagesApi: MessagesApi @@ -135,31 +132,7 @@ class Users( * @param username Username to lookup * @return View of user projects page */ - def showProjects(username: String): Action[AnyContent] = OreAction.asyncF { implicit request => - for { - u <- users - .withName(username) - .toZIOWithError(notFound) - // TODO include orga projects? - t1 <- ( - getOrga(username).option, - UserData.of(request, u) - ).parTupled - (orga, userData) = t1 - t2 <- ( - OrganizationData.of[Task](orga).value.orDie, - ScopedOrganizationData.of(request.currentUser, orga).value - ).parTupled - (orgaData, scopedOrgaData) = t2 - } yield { - Ok( - views.users.projects( - userData, - orgaData.flatMap(a => scopedOrgaData.map(b => (a, b))) - ) - ) - } - } + def showProjects(username: String): Action[AnyContent] = OreAction(implicit request => Ok(views.home())) /** * Submits a change to the specified user's tagline. @@ -190,27 +163,6 @@ class Users( } yield Redirect(ShowUser(user)) } - /** - * Sets the "locked" status of a User. - * - * @param username User to set status of - * @param locked True if user is locked - * @return Redirection to user page - */ - def setLocked(username: String, locked: Boolean, sso: Option[String], sig: Option[String]): Action[AnyContent] = { - VerifiedAction(username, sso, sig).asyncF { implicit request => - val user = request.user - - if (!locked) { - this.mailer.push(this.emails.create(user, this.emails.AccountUnlocked)) - } - - service - .update(user)(_.copy(isLocked = locked)) - .as(Redirect(ShowUser(username))) - } - } - /** * Shows a list of [[ore.models.user.User]]s that have created a * [[ore.models.project.Project]]. @@ -389,18 +341,18 @@ class Users( } ((projects, versions), pages) = res } yield { - val projectEntries = for (project <- projects) yield Sitemap.Entry(projectRoutes.Projects.show(user, project)) + val projectEntries = for (project <- projects) yield Sitemap.Entry(projectRoutes.Projects.show(user, project, "")) val versionEntries = for ((project, version) <- versions) yield Sitemap.Entry( - projectRoutes.Versions.show(user, project, version) + showVersion(user, project, version) ) val pageEntries = for ((project, page) <- pages) yield Sitemap.Entry( - projectRoutes.Pages.show(user, project, page) + showPage(user, project, page) ) Ok( diff --git a/ore/app/controllers/project/Channels.scala b/ore/app/controllers/project/Channels.scala deleted file mode 100644 index 87d271a10..000000000 --- a/ore/app/controllers/project/Channels.scala +++ /dev/null @@ -1,142 +0,0 @@ -package controllers.project - -import play.api.mvc.{Action, AnyContent} - -import controllers.{OreBaseController, OreControllerComponents} -import form.OreForms -import form.project.ChannelData -import ore.db.access.ModelView -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{ChannelTable, VersionTable} -import ore.models.project.Channel -import ore.permission.Permission -import util.syntax._ -import views.html.projects.{channels => views} - -import slick.lifted.TableQuery -import zio.interop.catz._ -import zio.{IO, Task} - -/** - * Controller for handling Channel related actions. - */ -class Channels(forms: OreForms)( - implicit oreComponents: OreControllerComponents -) extends OreBaseController { - - private val self = controllers.project.routes.Channels - - private def ChannelEditAction(author: String, slug: String) = - AuthedProjectAction(author, slug, requireUnlock = true).andThen(ProjectPermissionAction(Permission.EditChannel)) - - /** - * Displays a view of the specified Project's Channels. - * - * @param author Project owner - * @param slug Project slug - * @return View of channels - */ - def showList(author: String, slug: String): Action[AnyContent] = ChannelEditAction(author, slug).asyncF { - implicit request => - val query = for { - channel <- TableQuery[ChannelTable] if channel.projectId === request.project.id.value - } yield (channel, TableQuery[VersionTable].filter(_.channelId === channel.id).length) - - service.runDBIO(query.result).map(listWithVersionCount => Ok(views.list(request.data, listWithVersionCount))) - } - - /** - * Creates a submitted channel for the specified Project. - * - * @param author Project owner - * @param slug Project slug - * @return Redirect to view of channels - */ - def create(author: String, slug: String): Action[ChannelData] = - ChannelEditAction(author, slug).asyncF( - parse.form(forms.ChannelEdit, onErrors = FormError(self.showList(author, slug))) - ) { request => - request.body - .addTo[Task](request.project) - .value - .orDie - .absolve - .mapError(Redirect(self.showList(author, slug)).withErrors(_)) - .as(Redirect(self.showList(author, slug))) - } - - /** - * Submits changes to an existing channel. - * - * @param author Project owner - * @param slug Project slug - * @param channelName Channel name - * @return View of channels - */ - def save(author: String, slug: String, channelName: String): Action[ChannelData] = - ChannelEditAction(author, slug).asyncF( - parse.form(forms.ChannelEdit, onErrors = FormError(self.showList(author, slug))) - ) { request => - request.body - .saveTo(request.project, channelName) - .toZIO - .mapError(Redirect(self.showList(author, slug)).withErrors(_)) - .as(Redirect(self.showList(author, slug))) - } - - /** - * Irreversibly deletes the specified channel. - * - * @param author Project owner - * @param slug Project slug - * @param channelName Channel name - * @return View of channels - */ - def delete(author: String, slug: String, channelName: String): Action[AnyContent] = - ChannelEditAction(author, slug).asyncF { implicit request => - val channelsAccess = request.project.channels(ModelView.later(Channel)) - - val ourChannel = channelsAccess.find(_.name === channelName) - val ourChannelVersions = for { - channel <- ourChannel - version <- TableQuery[VersionTable] if version.channelId === channel.id - } yield version - - val moreThanOneChannelR = channelsAccess.size =!= 1 - val isChannelEmptyR = ourChannelVersions.size === 0 - val nonEmptyChannelsR = channelsAccess.query - .map(channel => TableQuery[VersionTable].filter(_.channelId === channel.id).length =!= 0) - .filter(identity) - .length - val reviewedChannelsCount = channelsAccess.count(!_.isNonReviewed) > 1 - - val query = for { - channel <- ourChannel - } yield ( - channel, - moreThanOneChannelR, - isChannelEmptyR || nonEmptyChannelsR > 1, - channel.isNonReviewed || reviewedChannelsCount - ) - - for { - t <- service.runDBIO(query.result.headOption).get.orElseFail(NotFound) - (channel, notLast, notLastNonEmpty, notLastReviewed) = t - _ <- { - val errorSeq = Seq( - notLast -> "error.channel.last", - notLastNonEmpty -> "error.channel.lastNonEmpty", - notLastReviewed -> "error.channel.lastReviewed" - ).collect { - case (success, msg) if !success => msg - } - - if (errorSeq.isEmpty) - IO.succeed(()) - else - IO.fail(Redirect(self.showList(author, slug)).withErrors(errorSeq.toList)) - } - _ <- projects.deleteChannel(request.project, channel) - } yield Redirect(self.showList(author, slug)) - } -} diff --git a/ore/app/controllers/project/Pages.scala b/ore/app/controllers/project/Pages.scala deleted file mode 100644 index 97f49d871..000000000 --- a/ore/app/controllers/project/Pages.scala +++ /dev/null @@ -1,266 +0,0 @@ -package controllers.project - -import java.nio.charset.StandardCharsets - -import play.api.libs.json.JsValue -import play.api.mvc.{Action, AnyContent} -import play.utils.UriEncoding - -import controllers.{OreBaseController, OreControllerComponents} -import form.OreForms -import form.project.PageSaveForm -import ore.StatTracker -import ore.db.access.ModelView -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.PageTable -import ore.db.{DbRef, Model} -import ore.markdown.MarkdownRenderer -import ore.models.{Job, JobInfo} -import ore.models.project.{Page, Project} -import ore.models.user.{LoggedActionPage, LoggedActionType} -import ore.permission.Permission -import ore.util.StringUtils._ -import util.UserActionLogger -import util.syntax._ -import views.html.projects.{pages => views} - -import cats.syntax.all._ -import zio.interop.catz._ -import zio.{IO, Task, UIO} - -/** - * Controller for handling Page related actions. - */ -class Pages(forms: OreForms, stats: StatTracker[UIO])( - implicit oreComponents: OreControllerComponents, - renderer: MarkdownRenderer -) extends OreBaseController { - - private val self = controllers.project.routes.Pages - - private def PageEditAction(author: String, slug: String) = - AuthedProjectAction(author, slug, requireUnlock = true).andThen(ProjectPermissionAction(Permission.EditPage)) - - private val childPageQuery = { - def childPageQueryFunction(parentSlug: Rep[String], childSlug: Rep[String]) = { - val q = TableQuery[PageTable] - val parentPages = q.filter(_.slug.toLowerCase === parentSlug.toLowerCase).map(_.id) - val childPage = - q.filter(page => (page.parentId in parentPages) && page.slug.toLowerCase === childSlug.toLowerCase) - childPage.take(1) - } - - Compiled(childPageQueryFunction _) - } - - def pageParts(page: String): List[String] = - page.split("/").map(page => UriEncoding.decodePathSegment(page, StandardCharsets.UTF_8)).toList - - /** - * Return the best guess of the page - */ - def findPage(project: Model[Project], page: String): IO[Unit, Model[Page]] = pageParts(page) match { - case parent :: child :: Nil => service.runDBIO(childPageQuery((parent, child)).result.headOption).get.orElseFail(()) - case single :: Nil => - project - .pages(ModelView.now(Page)) - .find(p => p.slug.toLowerCase === single.toLowerCase && p.parentId.isEmpty) - .toZIO - case _ => IO.fail(()) - } - - def queryProjectPagesAndFindSpecific( - project: Model[Project], - page: String - ): IO[Unit, (Seq[(Model[Page], Seq[Model[Page]])], Model[Page])] = - projects - .queryProjectPages(project) - .map { pages => - def pageEqual(name: String): Model[Page] => Boolean = _.slug.toLowerCase == name.toLowerCase - def findUpper(name: String) = pages.find(t => pageEqual(name)(t._1)) - - val res = pageParts(page) match { - case parent :: child :: Nil => findUpper(parent).map(_._2).flatMap(_.find(pageEqual(child))) - case single :: Nil => findUpper(single).map(_._1) - case _ => None - } - - res.tupleLeft(pages) - } - .get - .orElseFail(()) - - /** - * Displays the specified page. - * - * @param author Project owner - * @param slug Project slug - * @param page Page name - * @return View of page - */ - def show(author: String, slug: String, page: String): Action[AnyContent] = ProjectAction(author, slug).asyncF { - implicit request => - queryProjectPagesAndFindSpecific(request.project, page).orElseFail(notFound).flatMap { - case (pages, p) => - val pageCount = pages.size + pages.map(_._2.size).sum - val parentPage = - if (pages.map(_._1).contains(p)) None - else pages.collectFirst { case (pp, subPage) if subPage.contains(p) => pp } - - this.stats.projectViewed( - IO.succeed( - Ok( - views.view( - request.data, - request.scoped, - Model.unwrapNested[Seq[(Model[Page], Seq[Page])]](pages), - p, - Model.unwrapNested(parentPage), - pageCount - ) - ) - ) - ) - } - } - - /** - * Displays the documentation page editor for the specified project and page - * name. - * - * @param author Owner name - * @param slug Project slug - * @param pageName Page name - * @return Page editor - */ - def showEditor(author: String, slug: String, pageName: String): Action[AnyContent] = - PageEditAction(author, slug).asyncF { implicit request => - queryProjectPagesAndFindSpecific(request.project, pageName).orElseFail(notFound).map { - case (pages, p) => - val pageCount = pages.size + pages.map(_._2.size).sum - val parentPage = pages.collectFirst { case (pp, page) if page.contains(p) => pp } - - Ok( - views.view( - request.data, - request.scoped, - Model.unwrapNested[Seq[(Model[Page], Seq[Page])]](pages), - p, - Model.unwrapNested(parentPage), - pageCount, - editorOpen = true - ) - ) - } - } - - /** - * Renders the submitted page content and returns the result. - * - * @return Rendered content - */ - def showPreview(): Action[JsValue] = Action(parse.json) { implicit request => - Ok(renderer.render((request.body \ "raw").as[String])) - } - - /** - * Saves changes made on a documentation page. - * - * @param author Owner name - * @param slug Project slug - * @param page Page name - * @return Project home - */ - def save(author: String, slug: String, page: String): Action[PageSaveForm] = - PageEditAction(author, slug).asyncF( - parse.form(forms.PageEdit, onErrors = FormError(self.show(author, slug, page))) - ) { implicit request => - val pageData = request.body - val content = pageData.content - val project = request.project - val parentId = pageData.parentId - - for { - rootPages <- service.runDBIO(project.rootPages(ModelView.raw(Page)).result) - - _ <- { - val hasParent = parentId.isDefined - val parentExists = rootPages - .filter(_.name != Page.homeName) - .exists(p => parentId.contains(p.id.value)) - - if (hasParent && !parentExists) - IO.fail(BadRequest("Invalid parent ID.")) - else - IO.succeed(()) - } - - _ <- { - if (page == Page.homeName && !content.exists(_.length >= Page.minLength)) { - IO.fail(Redirect(self.show(author, slug, page)).withError("error.minLength")) - } else { - IO.succeed(()) - } - } - - parts = page.split("/") - getOrCreate = (parentId: Option[DbRef[Page]], part: Int) => { - val pageName = pageData.name.getOrElse(parts(part)) - //For some reason Scala doesn't want to use the implicit monad here - project.getOrCreatePage[UIO](pageName, parentId, content) - } - - createdPage <- { - if (parts.size == 2) { - service - .runDBIO( - project - .pages(ModelView.later(Page)) - .find(equalsIgnoreCase(_.slug, parts(0))) - .map(_.id) - .result - .headOption - ) - .flatMap(getOrCreate(_, 1)) - } else { - getOrCreate(parentId, 0) - } - } - _ <- content.fold(IO.succeed(createdPage)) { newPage => - val oldPage = createdPage.contents - val updatePage = service.update(createdPage)(_.copy(contents = newPage)) - - val addForumJob = if (createdPage.isHome) { - service.insert(Job.UpdateDiscourseProjectTopic.newJob(project.id).toJob).unit - } else IO.unit - - val log = UserActionLogger.log( - request.request, - LoggedActionType.ProjectPageEdited, - createdPage.id, - newPage, - oldPage - )(LoggedActionPage(_, Some(createdPage.projectId))) - - updatePage <* log <* addForumJob - } - } yield Redirect(self.show(author, slug, page)) - } - - /** - * Irreversibly deletes the specified Page from the specified Project. - * - * @param author Project owner - * @param slug Project slug - * @param page Page name - * @return Redirect to Project homepage - */ - def delete(author: String, slug: String, page: String): Action[AnyContent] = - PageEditAction(author, slug).asyncF { request => - findPage(request.project, page) - .flatMap(service.delete(_).unit) - .either - .as(Redirect(routes.Projects.show(author, slug))) - } - -} diff --git a/ore/app/controllers/project/Projects.scala b/ore/app/controllers/project/Projects.scala index 54b95c50c..c1c337eee 100644 --- a/ore/app/controllers/project/Projects.scala +++ b/ore/app/controllers/project/Projects.scala @@ -4,17 +4,17 @@ import java.nio.file.{Files, Path} import java.security.MessageDigest import java.util.Base64 +import scala.annotation.unused import scala.concurrent.duration._ import scala.jdk.CollectionConverters._ -import play.api.i18n.MessagesApi import play.api.libs.Files.TemporaryFile import play.api.mvc._ import controllers.sugar.Requests.AuthRequest import controllers.{OreBaseController, OreControllerComponents} import form.OreForms -import form.project.{DiscussionReplyForm, FlagForm} +import form.project.FlagForm import models.viewhelper.ScopedOrganizationData import ore.StatTracker import ore.db.access.ModelView @@ -23,104 +23,32 @@ import ore.db.impl.schema.UserTable import ore.db.{DbRef, Model} import ore.markdown.MarkdownRenderer import ore.member.MembershipDossier -import ore.models.{Job, JobInfo} -import ore.models.api.ProjectApiKey -import ore.models.organization.Organization import ore.models.project._ -import ore.models.project.factory.ProjectFactory import ore.models.user._ import ore.models.user.role.ProjectUserRole import ore.permission._ -import ore.util.OreMDC -import ore.util.StringUtils._ import _root_.util.syntax._ -import util.{FileIO, UserActionLogger} +import util.UserActionLogger import views.html.{projects => views} import cats.syntax.all._ -import com.typesafe.scalalogging -import zio.blocking.Blocking import zio.interop.catz._ import zio.{IO, Task, UIO, ZIO} /** * Controller for handling Project related actions. */ -class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory)( +class Projects(stats: StatTracker[UIO], forms: OreForms)( implicit oreComponents: OreControllerComponents, - fileIO: FileIO[ZIO[Blocking, Throwable, *]], - messagesApi: MessagesApi, renderer: MarkdownRenderer ) extends OreBaseController { private val self = controllers.project.routes.Projects - private val Logger = scalalogging.Logger("Projects") - private val MDCLogger = scalalogging.Logger.takingImplicit[OreMDC](Logger.underlying) - private def SettingsEditAction(author: String, slug: String) = - AuthedProjectAction(author, slug, requireUnlock = true) + AuthedProjectAction(author, slug) .andThen(ProjectPermissionAction(Permission.EditProjectSettings)) - private def MemberEditAction(author: String, slug: String) = - AuthedProjectAction(author, slug, requireUnlock = true) - .andThen(ProjectPermissionAction(Permission.ManageProjectMembers)) - - /** - * Displays the "create project" page. - * - * @return Create project view - */ - def showCreator(): Action[AnyContent] = UserLock().asyncF { implicit request => - for { - orgas <- request.user.organizations.allFromParent - createOrga <- orgas.toVector - .parTraverse(request.user.permissionsIn[Model[Organization], UIO](_).map(_.has(Permission.CreateProject))) - } yield { - val createdOrgas = orgas.zip(createOrga).collect { - case (orga, true) => orga - } - Ok(views.create(createdOrgas, request.user)) - } - } - - def createProject(): Action[AnyContent] = UserLock().asyncF { implicit request => - val user = request.user - for { - _ <- ZIO - .fromOption(factory.getUploadError(user)) - .flip - .mapError(Redirect(self.showCreator()).withError(_)) - organisationUserCanUploadTo <- orgasUserCanUploadTo(user) - settings <- forms - .projectCreate(organisationUserCanUploadTo.toSeq) - .bindZIO(FormErrorLocalized(self.showCreator())) - owner <- settings.ownerId - .filter(_ != user.id.value) - .fold(IO.succeed(user): IO[Result, Model[User]])( - ModelView.now(User).get(_).toZIOWithError(Redirect(self.showCreator()).withError("Owner not found")) - ) - project <- factory.createProject(owner, settings.asTemplate).mapError(Redirect(self.showCreator()).withError(_)) - _ <- projects.refreshHomePage(MDCLogger) - } yield Redirect(self.show(project.ownerName, project.slug)) - } - - private def orgasUserCanUploadTo(user: Model[User]): UIO[Set[DbRef[Organization]]] = { - for { - all <- user.organizations.allFromParent - canCreate <- all.toVector.parTraverse(org => - user.permissionsIn[Model[Organization], UIO](org).map(_.has(Permission.CreateProject)).tupleLeft(org.id.value) - ) - } yield { - // Filter by can Create Project - val others = canCreate.collect { - case (id, true) => id - } - - others.toSet + user.id // Add self - } - } - /** * Displays the Project with the specified author and name. * @@ -128,70 +56,9 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory * @param slug Project slug * @return View of project */ - def show(author: String, slug: String): Action[AnyContent] = ProjectAction(author, slug).asyncF { implicit request => - for { - t <- (projects.queryProjectPages(request.project), request.project.homePage).parTupled - (pages, homePage) = t - pageCount = pages.size + pages.map(_._2.size).sum - res <- stats.projectViewed( - UIO.succeed( - Ok( - views.pages.view( - request.data, - request.scoped, - Model.unwrapNested[Seq[(Model[Page], Seq[Page])]](pages), - homePage, - None, - pageCount - ) - ) - ) - ) - } yield res - } - - /** - * Displays the "discussion" tab within a Project view. - * - * @param author Owner of project - * @param slug Project slug - * @return View of project - */ - def showDiscussion(author: String, slug: String): Action[AnyContent] = ProjectAction(author, slug).asyncF { - implicit request => this.stats.projectViewed(UIO.succeed(Ok(views.discuss(request.data, request.scoped)))) - } - - /** - * Posts a new discussion reply to the forums. - * - * @param author Project owner - * @param slug Project slug - * @return View of discussion with new post - */ - def postDiscussionReply(author: String, slug: String): Action[DiscussionReplyForm] = - AuthedProjectAction(author, slug).asyncF( - parse.form(forms.ProjectReply, onErrors = FormError(self.showDiscussion(author, slug))) - ) { implicit request => - val formData = request.body - if (request.project.topicId.isEmpty) - IO.fail(BadRequest) - else { - // Do forum post and display errors to user if any - for { - poster <- { - ZIO - .fromOption(formData.poster) - .flatMap { posterName => - users.requestPermission(request.user, posterName, Permission.PostAsOrganization).toZIO - } - .orElseFail(request.user) - .either - .map(_.merge) - } - topicId <- ZIO.fromOption(request.project.topicId).orElseFail(BadRequest) - _ <- service.insert(Job.PostDiscourseReply.newJob(topicId, poster.name, formData.content).toJob) - } yield Redirect(self.showDiscussion(author, slug)) - } + def show(author: String, slug: String, @unused vuePage: String): Action[AnyContent] = + ProjectAction(author, slug).asyncF { implicit request => + stats.projectViewed(UIO.succeed(Ok(_root_.views.html.home()))) } /** @@ -235,7 +102,7 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory user.hasUnresolvedFlagFor(project, ModelView.now(Flag)).flatMap { // One flag per project, per user at a time - case true => IO.fail(BadRequest) + case true => IO.fail(BadRequest("Already submitted flag")) case false => project .flagFor(user, formData.reason, formData.comment) @@ -248,7 +115,7 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory s"Not flagged by ${user.name}" )(LoggedActionProject.apply) ) - .as(Redirect(self.show(author, slug)).flashing("reported" -> "true")) + .as(Redirect(self.show(author, slug, "")).flashing("reported" -> "true")) } } @@ -284,7 +151,6 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory title, call, request.data, - request.scoped, Model.unwrapNested(users), pageNum, pageSize @@ -350,7 +216,9 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory role .project[Task] .orDie - .flatMap(project => MembershipDossier.projectHasMemberships[Task].removeRole(project)(role.id).orDie) + .flatMap(project => + MembershipDossier.projectHasMemberships[Task].removeMember(project)(role.userId).orDie + ) .as(Ok) case STATUS_ACCEPT => service.update(role)(_.copy(isAccepted = true)).as(Ok) case STATUS_UNACCEPT => service.update(role)(_.copy(isAccepted = false)).as(Ok) @@ -381,7 +249,7 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory import MembershipDossier._ status match { case STATUS_DECLINE => - MembershipDossier.projectHasMemberships.removeRole(project)(role.id).as(Ok) + MembershipDossier.projectHasMemberships.removeMember(project)(role.userId).as(Ok) case STATUS_ACCEPT => service.update(role)(_.copy(isAccepted = true)).as(Ok) case STATUS_UNACCEPT => service.update(role)(_.copy(isAccepted = false)).as(Ok) case _ => IO.succeed(BadRequest) @@ -392,28 +260,6 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory res.orElseFail(NotFound) } - /** - * Shows the project manager or "settings" pane. - * - * @param author Project owner - * @param slug Project slug - * @return Project manager - */ - def showSettings(author: String, slug: String): Action[AnyContent] = SettingsEditAction(author, slug).asyncF { - implicit request => - request.project.obj.iconUrl - .zipPar( - request.project - .apiKeys(ModelView.now(ProjectApiKey)) - .one - .value - ) - .map { - case (iconUrl, deployKey) => - Ok(views.settings(request.data, request.scoped, deployKey, iconUrl)) - } - } - /** * Uploads a new icon to be saved for the specified [[ore.models.project.Project]]. * @@ -424,22 +270,22 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory def uploadIcon(author: String, slug: String): Action[MultipartFormData[TemporaryFile]] = SettingsEditAction(author, slug)(parse.multipartFormData).asyncF { implicit request => request.body.file("icon") match { - case None => IO.fail(Redirect(self.showSettings(author, slug)).withError("error.noFile")) + case None => IO.fail(Redirect(self.show(author, slug, "")).withError("error.noFile")) case Some(tmpFile) => - val data = request.data - val pendingDir = projectFiles.getPendingIconDir(data.project.ownerName, data.project.name) + val data = request.data + val dir = projectFiles.getIconDir(data.project.ownerName, data.project.name) import zio.blocking._ - val notExist = effectBlocking(Files.notExists(pendingDir)) - val createDir = effectBlocking(Files.createDirectories(pendingDir)) + val notExist = effectBlocking(Files.notExists(dir)) + val createDir = effectBlocking(Files.createDirectories(dir)) val deleteFile = (p: Path) => effectBlocking(Files.delete(p)) - val deleteFiles = effectBlocking(Files.list(pendingDir)) + val deleteFiles = effectBlocking(Files.list(dir)) .map(_.iterator().asScala) .flatMap(it => ZIO.foreachParN_(config.performance.nioBlockingFibers)(it.to(Iterable))(deleteFile)) - val moveFile = effectBlocking(tmpFile.ref.moveTo(pendingDir.resolve(tmpFile.filename), replace = true)) + val moveFile = effectBlocking(tmpFile.ref.moveTo(dir.resolve(tmpFile.filename), replace = true)) //todo data val log = UserActionLogger.log(request.request, LoggedActionType.ProjectIconChanged, data.project.id, "", "")( @@ -467,11 +313,8 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory op.fold(IO.succeed(()): ZIO[Blocking, Throwable, Unit])(p => effectBlocking(Files.delete(p))) val res = for { - icon <- projectFiles.getIconPath(project) - _ <- deleteOptFile(icon) - pendingIcon <- projectFiles.getPendingIconPath(project) - _ <- deleteOptFile(pendingIcon) - _ <- effectBlocking(Files.delete(projectFiles.getPendingIconDir(project.ownerName, project.name))) + icon <- projectFiles.getIconPath(project) + _ <- deleteOptFile(icon) //todo data _ <- UserActionLogger.log(request.request, LoggedActionType.ProjectIconChanged, project.id, "", "")( LoggedActionProject.apply @@ -480,156 +323,6 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory res.orDie } - /** - * Displays the specified [[ore.models.project.Project]]'s current pending - * icon, if any. - * - * @param author Project owner - * @param slug Project slug - * @return Pending icon - */ - def showPendingIcon(author: String, slug: String): Action[AnyContent] = - ProjectAction(author, slug).asyncF { implicit request => - projectFiles.getPendingIconPath(request.project.ownerName, request.project.name).map { - case None => notFound - case Some(path) => showImage(path) - } - } - - /** - * Removes a [[ProjectMember]] from the specified project. - * - * @param author Project owner - * @param slug Project slug - */ - def removeMember(author: String, slug: String): Action[String] = - MemberEditAction(author, slug).asyncF(parse.form(forms.ProjectMemberRemove)) { implicit request => - users - .withName(request.body) - .toZIOWithError(BadRequest) - .flatMap { user => - val project = request.data.project - MembershipDossier - .projectHasMemberships[Task] - .removeMember(project)(user.id) - .orDie - .zipRight( - UserActionLogger.log( - request.request, - LoggedActionType.ProjectMemberRemoved, - project.id, - s"'${user.name}' is not a member of ${project.ownerName}/${project.name}", - s"'${user.name}' is a member of ${project.ownerName}/${project.name}" - )(LoggedActionProject.apply) - ) - .as(Redirect(self.showSettings(author, slug))) - } - } - - /** - * Saves the specified Project from the settings manager. - * - * @param author Project owner - * @param slug Project slug - * @return View of project - */ - def save(author: String, slug: String): Action[AnyContent] = SettingsEditAction(author, slug).asyncF { - implicit request => - val data = request.data - for { - organisationUserCanUploadTo <- orgasUserCanUploadTo(request.user) - formData <- this.forms - .ProjectSave(organisationUserCanUploadTo.toSeq) - .bindZIO(FormErrorLocalized(self.showSettings(author, slug))) - _ <- formData - .save[ZIO[Blocking, Throwable, *]](data.project, MDCLogger) - .value - .orDie - .absolve - .mapError(Redirect(self.showSettings(author, slug)).withError(_)) - _ <- projects.refreshHomePage(MDCLogger) - _ <- UserActionLogger.log( - request.request, - LoggedActionType.ProjectSettingsChanged, - request.data.project.id, - "", - "" - )(LoggedActionProject.apply) - } yield Redirect(self.show(author, slug)) - } - - /** - * Renames the specified project. - * - * @param author Project owner - * @param slug Project slug - * @return Project homepage - */ - def rename(author: String, slug: String): Action[String] = - SettingsEditAction(author, slug).asyncF(parse.form(forms.ProjectRename)) { implicit request => - val project = request.data.project - val newName = compact(request.body) - val oldName = request.project.name - - for { - available <- projects.isNamespaceAvailable(author, slugify(newName)) - _ <- ZIO.fromEither( - Either.cond(available, (), Redirect(self.showSettings(author, slug)).withError("error.nameUnavailable")) - ) - _ <- projects.rename(project, newName) - _ <- UserActionLogger.log( - request.request, - LoggedActionType.ProjectRenamed, - request.project.id, - s"$author/$newName", - s"$author/$oldName" - )(LoggedActionProject.apply) - _ <- projects.refreshHomePage(MDCLogger) - } yield Redirect(self.show(author, project.slug)) - } - - /** - * Sets the visible state of the specified Project. - * - * @param author Project owner - * @param slug Project slug - * @param visibility Project visibility - * @return Ok - */ - def setVisible(author: String, slug: String, visibility: Int): Action[AnyContent] = { - AuthedProjectAction(author, slug, requireUnlock = true) - .andThen(ProjectPermissionAction(Permission.Reviewer)) - .asyncF { implicit request => - val newVisibility = Visibility.withValue(visibility) - - val addForumJob = service.insert(Job.UpdateDiscourseProjectTopic.newJob(request.project.id).toJob).unit - - val forumVisbility = - if (Visibility.isPublic(newVisibility) != Visibility.isPublic(request.project.visibility)) { - addForumJob - } else IO.unit - - val projectVisibility = if (newVisibility.showModal) { - val comment = this.forms.NeedsChanges.bindFromRequest().get.trim - request.project.setVisibility(newVisibility, comment, request.user.id) - } else { - request.project.setVisibility(newVisibility, "", request.user.id) - } - - val log = UserActionLogger.log( - request.request, - LoggedActionType.ProjectVisibilityChange, - request.project.id, - newVisibility.nameKey, - Visibility.NeedsChanges.nameKey - )(LoggedActionProject.apply) - - (forumVisbility, projectVisibility).parTupled - .productR((log, projects.refreshHomePage(MDCLogger)).parTupled) - .as(Ok) - } - } - /** * Set a project that needed changes to the approval state * @param author Project owner @@ -650,73 +343,9 @@ class Projects(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory visibility *> log.unit } else IO.unit - effects.as(Redirect(self.show(request.project.ownerName, request.project.slug))) + effects.as(Redirect(self.show(request.project.ownerName, request.project.slug, ""))) } - /** - * Irreversibly deletes the specified project. - * - * @param author Project owner - * @param slug Project slug - * @return Home page - */ - def delete(author: String, slug: String): Action[AnyContent] = { - Authenticated.andThen(PermissionAction(Permission.HardDeleteProject)).asyncF { implicit request => - getProject(author, slug).flatMap { project => - hardDeleteProject(project) - .as(Redirect(ShowHome).withSuccess(request.messages.apply("project.deleted", project.name))) - } - } - } - - private def hardDeleteProject[A](project: Model[Project])(implicit request: AuthRequest[A]): UIO[Unit] = { - projects.delete(project) *> - UserActionLogger.logOption( - request, - LoggedActionType.ProjectVisibilityChange, - None, - "deleted", - project.visibility.nameKey - )(LoggedActionProject.apply) *> - projects.refreshHomePage(MDCLogger) - } - - /** - * Soft deletes the specified project. - * - * @param author Project owner - * @param slug Project slug - * @return Home page - */ - def softDelete(author: String, slug: String): Action[String] = - AuthedProjectAction(author, slug, requireUnlock = true) - .andThen(ProjectPermissionAction(Permission.DeleteProject)) - .asyncF(parse.form(forms.NeedsChanges)) { implicit request => - val oldProject = request.project - val comment = request.body.trim - - val ret = if (oldProject.visibility == Visibility.New) { - hardDeleteProject(oldProject)(request.request) - } else { - val oreVisibility = oldProject.setVisibility(Visibility.SoftDelete, comment, request.user.id) - - val forumVisibility = service.insert(Job.UpdateDiscourseProjectTopic.newJob(oldProject.id).toJob) - val log = UserActionLogger.log( - request.request, - LoggedActionType.ProjectVisibilityChange, - oldProject.id, - Visibility.SoftDelete.nameKey, - oldProject.visibility.nameKey - )(LoggedActionProject.apply) - - (oreVisibility, forumVisibility).parTupled - .zipRight((log, projects.refreshHomePage(MDCLogger)).parTupled) - .unit - } - - ret.as(Redirect(ShowHome).withSuccess(request.messages.apply("project.deleted", oldProject.name))) - } - /** * Show the flags that have been made on this project * diff --git a/ore/app/controllers/project/Versions.scala b/ore/app/controllers/project/Versions.scala index 339386830..a386a7041 100644 --- a/ore/app/controllers/project/Versions.scala +++ b/ore/app/controllers/project/Versions.scala @@ -2,8 +2,8 @@ package controllers.project import java.nio.file.Files._ import java.nio.file.{Files, StandardCopyOption} -import java.time.temporal.ChronoUnit import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit import java.util.UUID import scala.annotation.unused @@ -15,25 +15,20 @@ import play.filters.csrf.CSRF import controllers.sugar.Requests.{AuthRequest, OreRequest, ProjectRequest} import controllers.{OreBaseController, OreControllerComponents} -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.impl.schema.{ProjectTable, UserTable, VersionTable} import ore.db.{DbRef, Model} import ore.markdown.MarkdownRenderer -import ore.models.{Job, JobInfo} import ore.models.admin.VersionVisibilityChange import ore.models.project._ -import ore.models.project.factory.ProjectFactory -import ore.models.project.io.{PluginFile, PluginUpload} -import ore.models.user.{LoggedActionType, LoggedActionVersion, User} +import ore.models.project.io.PluginFile +import ore.models.user.User import ore.permission.Permission +import ore.rest.ApiV1ProjectsTable import ore.util.OreMDC -import ore.util.StringUtils._ import ore.{OreEnv, StatTracker} -import util.UserActionLogger import util.syntax._ import views.html.projects.{versions => views} @@ -50,7 +45,7 @@ import zio.{IO, Task, UIO, ZIO} /** * Controller for handling Version related actions. */ -class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory)( +class Versions(stats: StatTracker[UIO])( implicit oreComponents: OreControllerComponents, messagesApi: MessagesApi, env: OreEnv, @@ -62,375 +57,6 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory 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).asyncF { implicit request => - for { - version <- getVersion(request.project, versionString) - data <- VersionData.of[Task](request, version).orDie - response <- this.stats.projectViewed(UIO.succeed(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).asyncF(parse.form(forms.VersionDescription)) { implicit request => - for { - version <- getVersion(request.project, versionString) - oldDescription = version.description.getOrElse("") - newDescription = request.body.trim - _ <- service.update(version)(_.copy(description = Some(newDescription))) - _ <- service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob) - _ <- UserActionLogger.log( - request.request, - LoggedActionType.VersionDescriptionEdited, - version.id, - newDescription, - oldDescription - )(LoggedActionVersion(_, Some(version.projectId))) - } 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).asyncF { implicit request => - for { - version <- getVersion(request.project, versionString) - _ <- service.update(request.project)(_.copy(recommendedVersionId = Some(version.id))) - } 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)) - .asyncF { implicit request => - val newState = if (partial) ReviewState.PartiallyReviewed else ReviewState.Reviewed - for { - version <- getVersion(request.data.project, versionString) - _ <- service.update(version)( - _.copy( - reviewState = newState, - reviewerId = Some(request.user.id), - approvedAt = Some(OffsetDateTime.now()) - ) - ) - _ <- UserActionLogger.log( - request.request, - LoggedActionType.VersionReviewStateChanged, - version.id, - newState.toString, - version.reviewState.toString - )(LoggedActionVersion(_, Some(version.projectId))) - } 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( - UIO.succeed( - 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.project.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).asyncF { - 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"))) - - for { - data <- ZIO.fromEither(uploadData) - pendingVersion <- this.factory - .processSubsequentPluginUpload(data, user, request.data.project) - .mapError(err => Redirect(call).withError(err)) - _ <- pendingVersion.copy(authorId = user.id).cache[Task].orDie - } yield 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 suc2 = for { - project <- projects.withSlug(author, slug).get - pendingVersion <- ZIO.fromOption(this.factory.getPendingVersion(project, versionString)) - channels <- service.runDBIO(project.channels(ModelView.raw(Channel)).result) - } yield Ok( - views.create( - project.name, - project.pluginId, - project.slug, - project.ownerName, - project.description, - project.settings.forumSync, - Some(pendingVersion), - Model.unwrapNested(channels) - ) - ) - - suc2.orElseFail(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 => - for { - project <- getProject(author, slug) - // First get the pending Version - pendingVersion <- ZIO - .fromOption(this.factory.getPendingVersion(project, versionString)) - // Not found - .orElseFail(Redirect(self.showCreator(author, slug)).withError("error.plugin.timeout")) - // Get submitted channel - versionData <- this.forms.VersionCreate.bindZIO( - // Invalid channel - FormError(self.showCreatorWithMeta(author, slug, versionString)) - ) - - // Channel is valid - newPendingVersion = pendingVersion.copy( - channelName = versionData.channelName.trim, - channelColor = versionData.color, - createForumPost = versionData.forumPost, - description = versionData.content - ) - - alreadyExists <- newPendingVersion.exists[Task].orDie - - _ <- if (alreadyExists) - ZIO.fail(Redirect(self.showCreator(author, slug)).withError("error.plugin.versionExists")) - else ZIO.succeed(()) - - _ <- project - .channels(ModelView.now(Channel)) - .find(equalsIgnoreCase(_.name, newPendingVersion.channelName)) - .toZIO - .catchAll(_ => versionData.addTo[Task](project).value.orDie.absolve) - .mapError(Redirect(self.showCreatorWithMeta(author, slug, versionString)).withErrors(_)) - t <- newPendingVersion.complete(project, factory) - (newProject, newVersion, _, _) = t - _ <- { - if (versionData.recommended) - service - .update(newProject)(_.copy(recommendedVersionId = Some(newVersion.id))) - .unit - else - ZIO.unit - } - _ <- addUnstableTag(newVersion, versionData.unstable) - _ <- UserActionLogger.log( - request, - LoggedActionType.VersionUploaded, - newVersion.id, - "published", - "null" - )(LoggedActionVersion(_, Some(newVersion.projectId))) - } yield Redirect(self.show(author, slug, versionString)) - } - } - - private def addUnstableTag(version: Model[Version], unstable: Boolean) = { - if (unstable) { - service - .insert( - VersionTag( - versionId = version.id, - name = "Unstable", - data = None, - color = TagColor.Unstable - ) - ) - .unit - } else UIO.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)) - .asyncF(parse.form(forms.NeedsChanges)) { implicit request => - val comment = request.body - - for { - version <- getProjectVersion(author, slug, versionString) - _ <- UserActionLogger.log( - request, - LoggedActionType.VersionDeleted, - version.id, - s"Deleted: $comment", - s"${version.visibility}" - )(LoggedActionVersion(_, Some(version.projectId))) - _ <- projects.deleteVersion(version) - } yield 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)) - .asyncF(parse.form(forms.NeedsChanges)) { implicit request => - val comment = request.body - - for { - version <- getVersion(request.project, versionString) - _ <- projects.prepareDeleteVersion(version) - _ <- version.setVisibility(Visibility.SoftDelete, comment, request.user.id) - _ <- UserActionLogger.log( - request.request, - LoggedActionType.VersionDeleted, - version.id, - s"SoftDelete: $comment", - s"${version.visibility}" - )(LoggedActionVersion(_, Some(version.projectId))) - } yield 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)) - .asyncF(parse.form(forms.NeedsChanges)) { implicit request => - val comment = request.body - - for { - version <- getProjectVersion(author, slug, versionString) - _ <- version.setVisibility(Visibility.Public, comment, request.user.id) - _ <- UserActionLogger.log(request, LoggedActionType.VersionDeleted, version.id, s"Restore: $comment", "")( - LoggedActionVersion(_, Some(version.projectId)) - ) - } yield Redirect(self.showList(author, slug)) - } - } - def showLog(author: String, slug: String, versionString: String): Action[AnyContent] = { Authenticated .andThen(PermissionAction[AuthRequest](Permission.ViewLogs)) @@ -643,12 +269,14 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory ).withHeaders(CONTENT_DISPOSITION -> "inline; filename=\"README.txt\"") ) } else { - version.channel[Task].orDie.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 + val nonReviewed = version.tags.stability != Version.Stability.Stable + + //We return Ok here to make sure Chrome sets the cookie + //https://bugs.chromium.org/p/chromium/issues/detail?id=696204 + IO.succeed( Ok(views.unsafeDownload(project, version, nonReviewed, dlType)) .addingToSession(DownloadWarning.cookieKey(version.id) -> "set") - } + ) } } } @@ -743,17 +371,23 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory * @param slug Project slug * @return Sent file */ - def downloadRecommended(author: String, slug: String, token: Option[String]): Action[AnyContent] = { + def downloadRecommended(author: String, slug: String, token: Option[String]): Action[AnyContent] = ProjectAction(author, slug).asyncF { implicit request => - request.project - .recommendedVersion(ModelView.now(Version)) - .sequence - .subflatMap(identity) - .toRight(NotFound) - .toZIO + service + .runDBIO(firstPromotedVersion(request.project.id).result.headOption) + .get + .orElseFail(NotFound) .flatMap(sendVersion(request.project, _, token, confirm = false)) } - } + + private def firstPromotedVersion(id: DbRef[Project]) = + for { + hp <- TableQuery[ApiV1ProjectsTable] + p <- TableQuery[ProjectTable] if hp.id === p.id + v <- TableQuery[VersionTable] + if hp.id === id + if p.id === v.projectId && v.versionString === ((hp.promotedVersions ~> 0) +>> "version_string") + } yield v /** * Downloads the specified version as a JAR regardless of the original @@ -813,7 +447,7 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory if (fileName.endsWith(".jar")) IO.succeed(Ok.sendPath(path)) else { - val pluginFile = new PluginFile(path, projectOwner) + val pluginFile = new PluginFile(path, path.getFileName.toString, projectOwner) val jarName = fileName.substring(0, fileName.lastIndexOf('.')) + ".jar" val jarPath = env.tmp.resolve(project.ownerName).resolve(jarName) @@ -850,12 +484,10 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory */ def downloadRecommendedJar(author: String, slug: String, token: Option[String]): Action[AnyContent] = { ProjectAction(author, slug).asyncF { implicit request => - request.project - .recommendedVersion(ModelView.now(Version)) - .sequence - .subflatMap(identity) - .toRight(NotFound) - .toZIO + service + .runDBIO(firstPromotedVersion(request.project.id).result.headOption) + .get + .orElseFail(NotFound) .flatMap(sendJar(request.project, _, token)) } } @@ -891,14 +523,11 @@ class Versions(stats: StatTracker[UIO], forms: OreForms, factory: ProjectFactory */ def downloadRecommendedJarById(pluginId: String, token: Option[String]): Action[AnyContent] = { ProjectAction(pluginId).asyncF { implicit request => - val data = request.data - request.project - .recommendedVersion(ModelView.now(Version)) - .sequence - .subflatMap(identity) - .toRight(NotFound) - .toZIO - .flatMap(sendJar(data.project, _, token, api = true)) + service + .runDBIO(firstPromotedVersion(request.project.id).result.headOption) + .get + .orElseFail(NotFound) + .flatMap(sendJar(request.project, _, token, api = true)) } } } diff --git a/ore/app/db/impl/DbUpdateTask.scala b/ore/app/db/impl/DbUpdateTask.scala index c39ef18a9..30614f971 100644 --- a/ore/app/db/impl/DbUpdateTask.scala +++ b/ore/app/db/impl/DbUpdateTask.scala @@ -10,12 +10,12 @@ import ore.util.OreMDC import cats.syntax.all._ import com.typesafe.scalalogging +import doobie.implicits._ import zio.clock.Clock import zio._ class DbUpdateTask(config: OreConfig, lifecycle: ApplicationLifecycle, runtime: zio.Runtime[Clock])( - implicit projects: ProjectBase[Task], - service: ModelService[Task] + implicit service: ModelService[Task] ) { val interval: duration.Duration = duration.Duration.fromScala(config.ore.homepage.updateInterval) @@ -25,7 +25,7 @@ class DbUpdateTask(config: OreConfig, lifecycle: ApplicationLifecycle, runtime: Logger.info("DbUpdateTask starting") - private val homepageSchedule: Schedule[Any, Unit, Unit] = + private val materializedViewsSchedule: Schedule[Any, Unit, Unit] = Schedule .fixed(interval) .unit @@ -43,7 +43,14 @@ class DbUpdateTask(config: OreConfig, lifecycle: ApplicationLifecycle, runtime: runtime.unsafeRunToFuture(safeTask.repeat(schedule)) } - private val homepageTask = runningTask(projects.refreshHomePage(Logger), homepageSchedule) + private val materializedViewsTask = runningTask( + service.runDbCon( + sql"SELECT refreshProjectStats()" + .query[Option[Int]] + .unique *> sql"REFRESH MATERIALIZED VIEW promoted_versions".update.run.void + ), + materializedViewsSchedule + ) private def runMany(updates: Seq[doobie.Update0]) = service.runDbCon(updates.toList.traverse_(_.run)) @@ -54,6 +61,6 @@ class DbUpdateTask(config: OreConfig, lifecycle: ApplicationLifecycle, runtime: statSchedule ) - lifecycle.addStopHook(() => homepageTask.cancel()) + lifecycle.addStopHook(() => materializedViewsTask.cancel()) lifecycle.addStopHook(() => statsTask.cancel()) } diff --git a/ore/app/db/impl/query/AppQueries.scala b/ore/app/db/impl/query/AppQueries.scala index 85c50dbb4..dd2f1cf9e 100644 --- a/ore/app/db/impl/query/AppQueries.scala +++ b/ore/app/db/impl/query/AppQueries.scala @@ -6,22 +6,20 @@ import scala.concurrent.duration.FiniteDuration import models.querymodels._ import ore.data.project.Category +import ore.db.impl.query.DoobieOreProtocol import ore.db.{DbRef, Model} import ore.models.Job import ore.models.admin.LoggedActionViewModel -import ore.models.organization.Organization import ore.models.project._ -import ore.models.user.User import cats.data.NonEmptyList -import cats.syntax.all._ import doobie._ import doobie.implicits._ import doobie.postgres.implicits._ import doobie.implicits.javasql._ import doobie.implicits.javatime.JavaTimeLocalDateMeta -object AppQueries extends WebDoobieOreProtocol { +object AppQueries extends DoobieOreProtocol { //implicit val logger: LogHandler = createLogger("Database") @@ -32,8 +30,6 @@ object AppQueries extends WebDoobieOreProtocol { | sq.project_name, | sq.version_string, | sq.version_created_at, - | sq.channel_name, - | sq.channel_color, | sq.version_author, | sq.reviewer_id, | sq.reviewer_name, @@ -44,8 +40,6 @@ object AppQueries extends WebDoobieOreProtocol { | p.slug AS project_slug, | v.version_string, | v.created_at AS version_created_at, - | c.name AS channel_name, - | c.color AS channel_color, | vu.name AS version_author, | r.user_id AS reviewer_id, | ru.name AS reviewer_name, @@ -54,14 +48,14 @@ object AppQueries extends WebDoobieOreProtocol { | row_number() OVER (PARTITION BY (p.id, v.id) ORDER BY r.created_at DESC) AS row | FROM project_versions v | LEFT JOIN users vu ON v.author_id = vu.id - | INNER JOIN project_channels c ON v.channel_id = c.id | INNER JOIN projects p ON v.project_id = p.id | INNER JOIN users pu ON p.owner_id = pu.id | LEFT JOIN project_version_reviews r ON v.id = r.version_id | LEFT JOIN users ru ON ru.id = r.user_id | WHERE v.review_state = $reviewStateId | AND p.visibility != 5 - | AND v.visibility != 5) sq + | AND v.visibility != 5 + | AND v.stability = 'stable') sq | WHERE row = 1 | ORDER BY sq.project_name DESC, sq.version_string DESC""".stripMargin.query[UnsortedQueueEntry] } @@ -83,11 +77,11 @@ object AppQueries extends WebDoobieOreProtocol { } def getUnhealtyProjects(staleTime: FiniteDuration): Query0[UnhealtyProject] = { - sql"""|SELECT p.owner_name, p.slug, p.topic_id, p.post_id, coalesce(hp.last_updated, p.created_at), p.visibility - | FROM projects p JOIN home_projects hp ON p.id = hp.id + sql"""|SELECT p.owner_name, p.slug, p.topic_id, p.post_id, ps.last_updated, p.visibility + | FROM projects p JOIN project_stats ps ON p.id = ps.id | WHERE p.topic_id IS NULL | OR p.post_id IS NULL - | OR hp.last_updated > (now() - $staleTime::INTERVAL) + | OR ps.last_updated > (now() - $staleTime::INTERVAL) | OR p.visibility != 1""".stripMargin.query[UnhealtyProject] } @@ -207,14 +201,13 @@ object AppQueries extends WebDoobieOreProtocol { val catFilter = NonEmptyList.fromList(categories).map(Fragments.in(fr"p.category", _)) val res = ( - sql"SELECT p.id FROM home_projects p " ++ + sql"SELECT p.id FROM projects p JOIN project_stats ps ON p.id = ps.id " ++ Fragments.whereAndOpt(Some(queryFilter), catFilter) ++ fr"ORDER BY" ++ ordering.fragment ++ fr"LIMIT $limit OFFSET $offset" ).query[DbRef[Project]] - println(res.sql) res } } diff --git a/ore/app/db/impl/query/UserPagesQueries.scala b/ore/app/db/impl/query/UserPagesQueries.scala index 244709c5f..f8859a700 100644 --- a/ore/app/db/impl/query/UserPagesQueries.scala +++ b/ore/app/db/impl/query/UserPagesQueries.scala @@ -4,12 +4,13 @@ import java.time.OffsetDateTime import db.impl.access.UserBase.UserOrdering import ore.OreConfig +import ore.db.impl.query.DoobieOreProtocol import ore.permission.role.Role import doobie._ import doobie.implicits._ -object UserPagesQueries extends WebDoobieOreProtocol { +object UserPagesQueries extends DoobieOreProtocol { private def userFragOrder(reverse: Boolean, sortStr: String) = { val sort = if (reverse) fr"ASC" else fr"DESC" diff --git a/ore/app/form/OreForms.scala b/ore/app/form/OreForms.scala index 9c038c573..6213237df 100644 --- a/ore/app/form/OreForms.scala +++ b/ore/app/form/OreForms.scala @@ -1,31 +1,22 @@ package form -import java.net.{MalformedURLException, URL} - import scala.util.Try import play.api.data.Forms._ import play.api.data.format.Formatter -import play.api.data.validation.{Constraint, Invalid, Valid, ValidationError} -import play.api.data.{FieldMapping, Form, FormError, Mapping} +import play.api.data.{FieldMapping, Form, FormError} import controllers.sugar.Requests.ProjectRequest -import form.organization.{OrganizationAvatarUpdate, OrganizationMembersUpdate, OrganizationRoleSetBuilder} +import form.organization.{OrganizationMembersUpdate, OrganizationRoleSetBuilder} import form.project._ import ore.OreConfig -import ore.data.project.Category import ore.db.access.ModelView -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.{DbRef, Model, ModelService} +import ore.db.{Model, ModelService} import ore.models.api.ProjectApiKey -import ore.models.organization.Organization -import ore.models.project.factory.ProjectFactory -import ore.models.project.{Channel, Page} -import ore.models.user.role.ProjectUserRole +import ore.models.project.Page import util.syntax._ import cats.data.OptionT -import org.spongepowered.plugin.meta.PluginMetadata import zio.UIO /** @@ -34,41 +25,10 @@ import zio.UIO //noinspection ConvertibleToMethodValue class OreForms( implicit config: OreConfig, - factory: ProjectFactory, service: ModelService[UIO], runtime: zio.Runtime[Any] ) { - val url: Mapping[String] = text.verifying("error.url.invalid", text => { - if (text.isEmpty) - true - else { - try { - new URL(text) - true - } catch { - case _: MalformedURLException => - false - } - } - }) - - /** - * Submits a member to be removed from a Project. - */ - lazy val ProjectMemberRemove = Form(single("username" -> nonEmptyText)) - - /** - * Submits changes to a [[ore.models.project.Project]]'s - * [[ProjectUserRole]]s. - */ - lazy val ProjectMemberRoles = Form( - mapping( - "users" -> list(longNumber), - "roles" -> list(text) - )(ProjectRoleSetBuilder.apply)(ProjectRoleSetBuilder.unapply) - ) - /** * Submits a flag on a project for further review. */ @@ -76,81 +36,6 @@ class OreForms( mapping("flag-reason" -> number, "comment" -> nonEmptyText)(FlagForm.apply)(FlagForm.unapply) ) - /** - * This is a Constraint checker for the ownerId that will search the list allowedIds to see if the number is in it. - * @param allowedIds number that are allowed as ownerId - * @return Constraint - */ - def ownerIdInList[A](allowedIds: Seq[DbRef[A]]): Constraint[Option[DbRef[A]]] = - Constraint("constraints.check") { ownerId => - val errors = - if (ownerId.isDefined && !allowedIds.contains(ownerId.get)) Seq(ValidationError("error.plugin")) - else Nil - if (errors.isEmpty) Valid - else Invalid(errors) - } - - val category: FieldMapping[Category] = of[Category](new Formatter[Category] { - override def bind(key: String, data: Map[String, String]): Either[Seq[FormError], Category] = - data - .get(key) - .flatMap(s => Category.values.find(_.title == s)) - .toRight(Seq(FormError(key, "error.project.categoryNotFound", Nil))) - - override def unbind(key: String, value: Category): Map[String, String] = Map(key -> value.title) - }) - - def projectCreate(organisationUserCanUploadTo: Seq[DbRef[Organization]]) = Form( - mapping( - "name" -> text, - "pluginId" -> nonEmptyText(maxLength = 64) - .verifying("Not a valid plugin id", PluginMetadata.ID_PATTERN.matcher(_).matches()), - "category" -> category, - "description" -> optional(text), - "owner" -> optional(longNumber).verifying(ownerIdInList(organisationUserCanUploadTo)) - )(ProjectCreateForm.apply)(ProjectCreateForm.unapply) - ) - - /** - * Submits settings changes for a Project. - */ - def ProjectSave(organisationUserCanUploadTo: Seq[DbRef[Organization]]) = - Form( - mapping( - "category" -> text, - "homepage" -> url, - "issues" -> url, - "source" -> url, - "support" -> url, - "license-name" -> text, - "license-url" -> url, - "description" -> text, - "users" -> list(longNumber), - "roles" -> list(text), - "userUps" -> list(text), - "roleUps" -> list(text), - "update-icon" -> boolean, - "owner" -> optional(longNumber).verifying(ownerIdInList(organisationUserCanUploadTo)), - "forum-sync" -> boolean, - "keywords" -> text - )(ProjectSettingsForm.apply)(ProjectSettingsForm.unapply) - ) - - /** - * Submits a name change for a project. - */ - lazy val ProjectRename = Form(single("name" -> text)) - - /** - * Submits a post reply for a project discussion. - */ - lazy val ProjectReply = Form( - mapping( - "content" -> text(minLength = Page.minLength, maxLength = Page.maxLength), - "poster" -> optional(nonEmptyText) - )(DiscussionReplyForm.apply)(DiscussionReplyForm.unapply) - ) - /** * Submits a list of organization members to be invited. */ @@ -162,16 +47,6 @@ class OreForms( )(OrganizationRoleSetBuilder.apply)(OrganizationRoleSetBuilder.unapply) ) - /** - * Submits an avatar update for an [[Organization]]. - */ - lazy val OrganizationUpdateAvatar = Form( - mapping( - "avatar-method" -> nonEmptyText, - "avatar-url" -> optional(url) - )(OrganizationAvatarUpdate.apply)(OrganizationAvatarUpdate.unapply) - ) - /** * Submits an organization member for removal. */ @@ -189,74 +64,11 @@ class OreForms( )(OrganizationMembersUpdate.apply)(OrganizationMembersUpdate.unapply) ) - /** - * Submits a new Channel for a Project. - */ - lazy val ChannelEdit = Form( - mapping( - "channel-input" -> text.verifying( - "Invalid channel name.", - config.isValidChannelName(_) - ), - "channel-color-input" -> text.verifying( - "Invalid channel color.", - c => Channel.Colors.exists(_.hex.equalsIgnoreCase(c)) - ), - "non-reviewed" -> default(boolean, false) - )(ChannelData.apply)(ChannelData.unapply) - ) - - /** - * Submits changes on a documentation page. - */ - lazy val PageEdit = Form( - mapping( - "parent-id" -> optional(longNumber), - "name" -> optional(text), - "content" -> optional( - text( - maxLength = Page.maxLengthPage - ) - ) - )(PageSaveForm.apply)(PageSaveForm.unapply).verifying( - "error.maxLength", - pageSaveForm => { - val isHome = pageSaveForm.parentId.isEmpty && pageSaveForm.name.contains(Page.homeName) - val pageSize = pageSaveForm.content.getOrElse("").length - if (isHome) - pageSize <= Page.maxLength - else - pageSize <= Page.maxLengthPage - } - ) - ) - /** * Submits a tagline change for a User. */ lazy val UserTagline = Form(single("tagline" -> text)) - /** - * Submits a new Version. - */ - lazy val VersionCreate = Form( - mapping( - "unstable" -> boolean, - "recommended" -> boolean, - "channel-input" -> text.verifying("Invalid channel name.", config.isValidChannelName(_)), - "channel-color-input" -> text - .verifying("Invalid channel color.", c => Channel.Colors.exists(_.hex.equalsIgnoreCase(c))), - "non-reviewed" -> default(boolean, false), - "content" -> optional(text), - "forum-post" -> boolean - )(VersionData.apply)(VersionData.unapply) - ) - - /** - * Submits a change to a Version's description. - */ - lazy val VersionDescription = Form(single("content" -> text)) - def required(key: String): Seq[FormError] = Seq(FormError(key, "error.required", Nil)) def projectApiKey: FieldMapping[OptionT[UIO, Model[ProjectApiKey]]] = @@ -274,26 +86,11 @@ class OreForms( def ProjectApiKeyRevoke = Form(single("id" -> projectApiKey)) - def channel(implicit request: ProjectRequest[_]): FieldMapping[OptionT[UIO, Model[Channel]]] = - of[OptionT[UIO, Model[Channel]]](new Formatter[OptionT[UIO, Model[Channel]]] { - def bind(key: String, data: Map[String, String]): Either[Seq[FormError], OptionT[UIO, Model[Channel]]] = - data - .get(key) - .map(channelOptF(_)) - .toRight(Seq(FormError(key, "api.deploy.channelNotFound", Nil))) - - def unbind(key: String, value: OptionT[UIO, Model[Channel]]): Map[String, String] = - runtime.unsafeRun(value.value).map(key -> _.name.toLowerCase).toMap - }) - - def channelOptF(c: String)(implicit request: ProjectRequest[_]): OptionT[UIO, Model[Channel]] = - request.data.project.channels(ModelView.now(Channel)).find(_.name.toLowerCase === c.toLowerCase) - def VersionDeploy(implicit request: ProjectRequest[_]) = Form( mapping( "apiKey" -> nonEmptyText, - "channel" -> channel, + "channel" -> optional(nonEmptyText), "recommended" -> default(boolean, true), "forumPost" -> default(boolean, request.data.project.settings.forumSync), "changelog" -> optional(text(minLength = Page.minLength, maxLength = Page.maxLength)) diff --git a/ore/app/form/organization/OrganizationMembersUpdate.scala b/ore/app/form/organization/OrganizationMembersUpdate.scala index 9866968e2..6e651cc88 100644 --- a/ore/app/form/organization/OrganizationMembersUpdate.scala +++ b/ore/app/form/organization/OrganizationMembersUpdate.scala @@ -42,7 +42,7 @@ case class OrganizationMembersUpdate( .build() .toVector .parTraverse_ { role => - val addRole = dossier.addRole(organization)(role.userId, role.copy(organizationId = orgId)) + val addRole = dossier.setRole(organization)(role.userId, role.copy(organizationId = orgId)) val sendNotif = service.insert( Notification( userId = role.userId, @@ -56,7 +56,7 @@ case class OrganizationMembersUpdate( } val orgUsersF = organization.memberships - .members(organization) + .membersIds(organization) .flatMap { members => members.toVector.parTraverse { mem => ModelView @@ -80,7 +80,7 @@ case class OrganizationMembersUpdate( userMemRole.toVector.parTraverse_ { case Some((mem, role)) => - organization.memberships.getRoles(organization)(mem.id).flatMap { roles => + organization.memberships.getMembership(organization)(mem.id).flatMap { roles => roles.toVector.parTraverse_(userRole => service.update(userRole)(_.copy(role = role))) } case None => F.unit diff --git a/ore/app/form/project/ChannelData.scala b/ore/app/form/project/ChannelData.scala deleted file mode 100644 index 7ba1a5d36..000000000 --- a/ore/app/form/project/ChannelData.scala +++ /dev/null @@ -1,17 +0,0 @@ -package form.project - -import ore.OreConfig -import ore.models.project.factory.ProjectFactory - -/** - * Concrete counterpart to [[TChannelData]]. - * - * @param channelName Channel name - * @param channelColorHex Channel color hex code - */ -case class ChannelData( - channelName: String, - protected val channelColorHex: String, - nonReviewed: Boolean -)(implicit val config: OreConfig, val factory: ProjectFactory) - extends TChannelData diff --git a/ore/app/form/project/DiscussionReplyForm.scala b/ore/app/form/project/DiscussionReplyForm.scala deleted file mode 100644 index c38959104..000000000 --- a/ore/app/form/project/DiscussionReplyForm.scala +++ /dev/null @@ -1,3 +0,0 @@ -package form.project - -case class DiscussionReplyForm(content: String, poster: Option[String]) diff --git a/ore/app/form/project/PageSaveForm.scala b/ore/app/form/project/PageSaveForm.scala deleted file mode 100644 index 7a2f93c17..000000000 --- a/ore/app/form/project/PageSaveForm.scala +++ /dev/null @@ -1,6 +0,0 @@ -package form.project - -import ore.db.DbRef -import ore.models.project.Page - -case class PageSaveForm(parentId: Option[DbRef[Page]], name: Option[String], content: Option[String]) diff --git a/ore/app/form/project/ProjectCreateForm.scala b/ore/app/form/project/ProjectCreateForm.scala deleted file mode 100644 index dbe0fc23a..000000000 --- a/ore/app/form/project/ProjectCreateForm.scala +++ /dev/null @@ -1,17 +0,0 @@ -package form.project - -import ore.data.project.Category -import ore.db.DbRef -import ore.models.project.factory.ProjectTemplate -import ore.models.user.User - -case class ProjectCreateForm( - name: String, - pluginId: String, - category: Category, - description: Option[String], - ownerId: Option[DbRef[User]] -) { - - def asTemplate: ProjectTemplate = ProjectTemplate(name, pluginId, category, description) -} diff --git a/ore/app/form/project/ProjectRoleSetBuilder.scala b/ore/app/form/project/ProjectRoleSetBuilder.scala deleted file mode 100644 index 30cceda5f..000000000 --- a/ore/app/form/project/ProjectRoleSetBuilder.scala +++ /dev/null @@ -1,12 +0,0 @@ -package form.project - -import ore.db.DbRef -import ore.models.user.User - -/** - * Concrete counterpart of [[TProjectRoleSetBuilder]]. - * - * @param users Users for result set - * @param roles Roles for result set - */ -case class ProjectRoleSetBuilder(users: List[DbRef[User]], roles: List[String]) extends TProjectRoleSetBuilder diff --git a/ore/app/form/project/ProjectSettingsForm.scala b/ore/app/form/project/ProjectSettingsForm.scala deleted file mode 100644 index ea8deefe1..000000000 --- a/ore/app/form/project/ProjectSettingsForm.scala +++ /dev/null @@ -1,168 +0,0 @@ -package form.project - -import scala.language.higherKinds - -import ore.data.project.Category -import ore.data.user.notification.NotificationType -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{ProjectRoleTable, UserTable} -import ore.db.{DbRef, Model, ModelService} -import ore.models.project.io.ProjectFiles -import ore.models.project.Project -import ore.models.user.{Notification, User} -import ore.permission.role.Role -import ore.util.OreMDC -import ore.util.StringUtils.noneIfEmpty -import util.FileIO -import util.syntax._ - -import cats.Parallel -import cats.data.{EitherT, NonEmptyList} -import cats.effect.Async -import cats.syntax.all._ -import com.typesafe.scalalogging.LoggerTakingImplicit -import slick.lifted.TableQuery - -/** - * Represents the configurable Project settings that can be submitted via a - * form. - */ -case class ProjectSettingsForm( - categoryName: String, - homepage: String, - issues: String, - source: String, - support: String, - licenseName: String, - licenseUrl: String, - description: String, - users: List[DbRef[User]], - roles: List[String], - userUps: List[String], - roleUps: List[String], - updateIcon: Boolean, - ownerId: Option[DbRef[User]], - forumSync: Boolean, - keywordsRaw: String -) extends TProjectRoleSetBuilder { - - def save[F[_]](project: Model[Project], logger: LoggerTakingImplicit[OreMDC])( - implicit fileManager: ProjectFiles[F], - fileIO: FileIO[F], - mdc: OreMDC, - service: ModelService[F], - F: Async[F], - par: Parallel[F] - ): EitherT[F, String, Model[Project]] = { - logger.debug("Saving project settings") - logger.debug(this.toString) - val newOwnerId = this.ownerId.getOrElse(project.ownerId) - - val queryNewOwnerName = TableQuery[UserTable].filter(_.id === newOwnerId).map(_.name) - - val keywords = keywordsRaw.split(" ").iterator.map(_.trim).filter(_.nonEmpty).toList - - val checkedKeywordsF = EitherT.fromEither[F] { - if (keywords.length > 5) - Left("error.project.tooManyKeywords") - else if (keywords.exists(_.length > 32)) - Left("error.maxLength") - else - Right(keywords) - } - - val updateProject = checkedKeywordsF.flatMapF { checkedKeywords => - service.runDBIO(queryNewOwnerName.result.headOption).flatMap[Either[String, (Model[Project], String)]] { - case Some(newOwnerName) => - service - .update(project)( - _.copy( - category = Category.values.find(_.title == this.categoryName).get, - description = noneIfEmpty(this.description), - ownerId = newOwnerId, - settings = Project.ProjectSettings( - keywords = checkedKeywords, - homepage = noneIfEmpty(this.homepage), - issues = noneIfEmpty(this.issues), - source = noneIfEmpty(this.source), - support = noneIfEmpty(this.support), - licenseUrl = noneIfEmpty(this.licenseUrl), - licenseName = if (this.licenseUrl.nonEmpty) Some(this.licenseName) else project.settings.licenseName, - forumSync = this.forumSync - ) - ) - ) - .map(p => Right(p -> newOwnerName)) - case None => F.pure(Left("user.notFound")) - } - } - - updateProject.semiflatMap { - case (newProject, newOwnerName) => - // Update icon - val moveIcon = if (this.updateIcon) { - fileManager.getPendingIconPath(newOwnerName, newProject.name).flatMap { pendingPathOpt => - pendingPathOpt.fold(F.unit) { pendingPath => - val iconDir = fileManager.getIconDir(newOwnerName, newProject.name) - - val notExist = fileIO.notExists(iconDir) - val createDirs = fileIO.createDirectories(iconDir) - val deleteFiles = fileIO.list(iconDir).use(ps => fileIO.traverseLimited(ps)(p => fileIO.delete(p))) - val move = fileIO.move(pendingPath, iconDir.resolve(pendingPath.getFileName)) - - notExist.ifM(createDirs, F.unit) *> deleteFiles *> move.void - } - } - } else F.unit - - // Add new roles - val dossier = newProject.memberships - val addRoles = this - .build() - .toVector - .parTraverse(role => dossier.addRole(newProject)(role.userId, role.copy(projectId = newProject.id))) - .flatMap { roles => - val notifications = roles.map { role => - Notification( - userId = role.userId, - originId = Some(newProject.ownerId), - notificationType = NotificationType.ProjectInvite, - messageArgs = NonEmptyList.of("notification.project.invite", role.role.title, newProject.name) - ) - } - - service.bulkInsert(notifications) - } - - val updateExistingRoles = { - // Update existing roles - val usersTable = TableQuery[UserTable] - // Select member userIds - service - .runDBIO(usersTable.filter(_.name.inSetBind(this.userUps)).map(_.id).result) - .flatMap { userIds => - val roles = this.roleUps.traverse { role => - Role.projectRoles - .find(_.value == role) - .fold(F.raiseError[Role](new RuntimeException("supplied invalid role type")))(F.pure) - } - - roles.map(xs => userIds.zip(xs)) - } - .map { - _.map { - case (userId, role) => updateMemberShip(userId).update(role) - } - } - .flatMap(updates => service.runDBIO(DBIO.sequence(updates))) - } - - moveIcon *> addRoles *> updateExistingRoles.as(newProject) - } - } - - private def memberShipUpdate(userId: Rep[DbRef[User]]) = - TableQuery[ProjectRoleTable].filter(_.userId === userId).map(_.roleType) - - private lazy val updateMemberShip = Compiled(memberShipUpdate _) -} diff --git a/ore/app/form/project/TChannelData.scala b/ore/app/form/project/TChannelData.scala deleted file mode 100644 index fd2293eb6..000000000 --- a/ore/app/form/project/TChannelData.scala +++ /dev/null @@ -1,125 +0,0 @@ -package form.project - -import scala.language.higherKinds - -import ore.OreConfig -import ore.data.Color -import ore.db.access.ModelView -import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.ChannelTable -import ore.db.{Model, ModelService} -import ore.models.project.factory.ProjectFactory -import ore.models.project.{Channel, Project} -import ore.util.StringUtils._ - -import cats.Monad -import cats.data.{EitherT, OptionT} -import cats.effect.syntax.all._ -import cats.syntax.all._ -import zio.Task -import zio.interop.catz._ - -/** - * Represents submitted [[Channel]] data. - */ -//TODO: Return Use Validated for the values in here -trait TChannelData { - - def config: OreConfig - def factory: ProjectFactory - - /** The [[Channel]] [[Color]] **/ - val color: Color = Channel.Colors.find(_.hex.equalsIgnoreCase(channelColorHex)).get - - /** Channel name **/ - def channelName: String - - /** Channel color hex **/ - protected def channelColorHex: String - - def nonReviewed: Boolean - - /** - * Attempts to add this ChannelData as a [[Channel]] to the specified - * [[Project]]. - * - * @param project Project to add Channel to - * @return Either the new channel or an error message - */ - def addTo[F[_]]( - project: Model[Project] - )( - implicit service: ModelService[F], - F: cats.effect.Effect[F], - runtime: zio.Runtime[Any] - ): EitherT[F, List[String], Model[Channel]] = { - val dbChannels = project.channels(ModelView.later(Channel)) - val conditions = ( - dbChannels.size <= config.ore.projects.maxChannels, - dbChannels.forall(!equalsIgnoreCase[ChannelTable](_.name, this.channelName)(_)), - dbChannels.forall(_.color =!= this.color) - ) - - EitherT.liftF(service.runDBIO(conditions.result)).flatMap { - case (underMaxSize, uniqueName, uniqueColor) => - val errors = List( - underMaxSize -> "A project may only have up to five channels.", - uniqueName -> "error.channel.duplicateName", - uniqueColor -> "error.channel.duplicateColor" - ).collect { - case (success, error) if !success => error - } - - val eff: Task[Model[Channel]] = factory.createChannel(project, channelName, color) - - if (errors.nonEmpty) EitherT.leftT[F, Model[Channel]](errors) - else EitherT.right[List[String]](eff.toIO.to[F]) - } - } - - /** - * Attempts to save this ChannelData to the specified [[Channel]] name in - * the specified [[Project]]. - * - * @param oldName Channel name to save to - * @param project Project of channel - * @return Error, if any - */ - def saveTo[F[_]]( - project: Model[Project], - oldName: String - )(implicit service: ModelService[F], F: Monad[F]): EitherT[F, List[String], Unit] = { - val otherDbChannels = project.channels(ModelView.later(Channel)).filterView(_.name =!= oldName) - val query = project.channels(ModelView.raw(Channel)).filter(_.name === oldName).map { channel => - ( - channel, - otherDbChannels.forall(!equalsIgnoreCase[ChannelTable](_.name, this.channelName)(_)), - otherDbChannels.forall(_.color =!= this.color), - !(otherDbChannels.forall(_.isNonReviewed) && nonReviewed) - ) - } - - OptionT(service.runDBIO(query.result.headOption)).toRight(List("error.channel.nowFound")).flatMap { - case (channel, uniqueName, uniqueColor, minOneReviewed) => - val errors = List( - uniqueName -> "error.channel.duplicateName", - uniqueColor -> "error.channel.duplicateColor", - minOneReviewed -> "error.channel.minOneReviewed" - ).collect { - case (success, error) if !success => error - } - - val effect = service.update(channel)( - _.copy( - name = channelName, - color = color, - isNonReviewed = nonReviewed - ) - ) - - if (errors.nonEmpty) EitherT.leftT[F, Unit](errors) - else EitherT.right[List[String]](effect.void) - } - } - -} diff --git a/ore/app/form/project/TProjectRoleSetBuilder.scala b/ore/app/form/project/TProjectRoleSetBuilder.scala deleted file mode 100644 index bc974519a..000000000 --- a/ore/app/form/project/TProjectRoleSetBuilder.scala +++ /dev/null @@ -1,16 +0,0 @@ -package form.project - -import form.RoleSetBuilder -import ore.db.DbRef -import ore.models.user.User -import ore.models.user.role.ProjectUserRole -import ore.permission.role.Role - -/** - * Takes form data and builds an uninitialized set of [[ProjectUserRole]]. - */ -trait TProjectRoleSetBuilder extends RoleSetBuilder[ProjectUserRole] { - - override def newRole(userId: DbRef[User], role: Role): ProjectUserRole = - ProjectUserRole(userId, -1L, role) -} diff --git a/ore/app/form/project/VersionData.scala b/ore/app/form/project/VersionData.scala deleted file mode 100644 index e83c2b071..000000000 --- a/ore/app/form/project/VersionData.scala +++ /dev/null @@ -1,22 +0,0 @@ -package form.project - -import ore.OreConfig -import ore.models.project.factory.ProjectFactory - -/** - * Represents submitted [[ore.models.project.Version]] data. - * - * @param channelName Name of channel - * @param channelColorHex Channel color hex - * @param recommended True if recommended version - */ -case class VersionData( - unstable: Boolean, - recommended: Boolean, - channelName: String, - protected val channelColorHex: String, - nonReviewed: Boolean, - content: Option[String], - forumPost: Boolean -)(implicit val config: OreConfig, val factory: ProjectFactory) - extends TChannelData diff --git a/ore/app/form/project/VersionDeployForm.scala b/ore/app/form/project/VersionDeployForm.scala index cce8444ff..7d97446fc 100644 --- a/ore/app/form/project/VersionDeployForm.scala +++ b/ore/app/form/project/VersionDeployForm.scala @@ -1,14 +1,8 @@ package form.project -import ore.db.Model -import ore.models.project.Channel - -import cats.data.OptionT -import zio.UIO - case class VersionDeployForm( apiKey: String, - channel: OptionT[UIO, Model[Channel]], + channel: Option[String], recommended: Boolean, createForumPost: Boolean, changelog: Option[String] diff --git a/ore/app/mail/Mailer.scala b/ore/app/mail/Mailer.scala index 296b505b5..3836707cf 100644 --- a/ore/app/mail/Mailer.scala +++ b/ore/app/mail/Mailer.scala @@ -2,7 +2,6 @@ package mail import java.security.Security import java.util.Date -import javax.inject.{Inject, Singleton} import javax.mail.Message.RecipientType import javax.mail.Session import javax.mail.internet.{InternetAddress, MimeMessage} diff --git a/ore/app/models/querymodels/ProjectListEntry.scala b/ore/app/models/querymodels/ProjectListEntry.scala deleted file mode 100644 index 1be8d82b5..000000000 --- a/ore/app/models/querymodels/ProjectListEntry.scala +++ /dev/null @@ -1,50 +0,0 @@ -package models.querymodels -import ore.OreConfig -import ore.data.project.{Category, ProjectNamespace} -import ore.models.project.Visibility -import ore.models.project.io.ProjectFiles -import ore.models.user.User -import util.syntax._ - -import zio.ZIO -import zio.blocking.Blocking - -case class ProjectListEntry( - namespace: ProjectNamespace, - visibility: Visibility, - views: Long, - downloads: Long, - stars: Long, - category: Category, - description: Option[String], - name: String, - version: Option[String], - tags: List[ViewTag] -) { - - def withIcon( - implicit projectFiles: ProjectFiles[ZIO[Blocking, Nothing, *]], - config: OreConfig - ): ZIO[Blocking, Nothing, ProjectListEntryWithIcon] = { - val iconF = projectFiles.getIconPath(namespace.ownerName, name).map(_.isDefined).map { - case true => controllers.project.routes.Projects.showIcon(namespace.ownerName, namespace.slug).url - case false => User.avatarUrl(namespace.ownerName) - } - - iconF.map { icon => - ProjectListEntryWithIcon( - namespace, - visibility, - views, - downloads, - stars, - category, - description, - name, - version, - tags, - icon - ) - } - } -} diff --git a/ore/app/models/querymodels/ProjectListEntryWithIcon.scala b/ore/app/models/querymodels/ProjectListEntryWithIcon.scala deleted file mode 100644 index 84513899a..000000000 --- a/ore/app/models/querymodels/ProjectListEntryWithIcon.scala +++ /dev/null @@ -1,18 +0,0 @@ -package models.querymodels - -import ore.data.project.{Category, ProjectNamespace} -import ore.models.project.Visibility - -case class ProjectListEntryWithIcon( - namespace: ProjectNamespace, - visibility: Visibility, - views: Long, - downloads: Long, - stars: Long, - category: Category, - description: Option[String], - name: String, - version: Option[String], - tags: List[ViewTag], - icon: String -) diff --git a/ore/app/models/querymodels/queueEntry.scala b/ore/app/models/querymodels/queueEntry.scala index 491aa4472..81ef4af6d 100644 --- a/ore/app/models/querymodels/queueEntry.scala +++ b/ore/app/models/querymodels/queueEntry.scala @@ -1,7 +1,6 @@ package models.querymodels import java.time.OffsetDateTime -import ore.data.Color import ore.data.project.ProjectNamespace import ore.db.DbRef import ore.models.user.User @@ -11,8 +10,6 @@ case class UnsortedQueueEntry( projectName: String, versionString: String, versionCreatedAt: OffsetDateTime, - channelName: String, - channelColor: Color, versionAuthor: Option[String], reviewerId: Option[DbRef[User]], reviewerName: Option[String], @@ -28,8 +25,6 @@ case class UnsortedQueueEntry( projectName, versionString, versionCreatedAt, - channelName, - channelColor, versionAuthor, reviewerId.get, reviewerName.get, @@ -44,8 +39,6 @@ case class UnsortedQueueEntry( projectName, versionString, versionCreatedAt, - channelName, - channelColor, versionAuthor ) ) @@ -57,8 +50,6 @@ case class ReviewedQueueEntry( projectName: String, versionString: String, versionCreatedAt: OffsetDateTime, - channelName: String, - channelColor: Color, versionAuthor: Option[String], reviewerId: DbRef[User], reviewerName: String, @@ -74,7 +65,5 @@ case class NotStartedQueueEntry( projectName: String, versionString: String, versionCreatedAt: OffsetDateTime, - channelName: String, - channelColor: Color, versionAuthor: Option[String] ) diff --git a/ore/app/ore/rest/ApiV1ProjectsTable.scala b/ore/app/ore/rest/ApiV1ProjectsTable.scala new file mode 100644 index 000000000..23dcb6853 --- /dev/null +++ b/ore/app/ore/rest/ApiV1ProjectsTable.scala @@ -0,0 +1,17 @@ +package ore.rest + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.project.Project + +import io.circe.Json + +class ApiV1ProjectsTable(tag: Tag) extends Table[ApiV1Project](tag, "apiv1_projects") { + + def id = column[DbRef[Project]]("id") + def promotedVersions = column[Json]("promoted_versions") + + override def * = (id, promotedVersions).<>((ApiV1Project.apply _).tupled, ApiV1Project.unapply) +} + +case class ApiV1Project(id: DbRef[Project], promotedVersions: Json) diff --git a/ore/app/ore/rest/FakeChannel.scala b/ore/app/ore/rest/FakeChannel.scala new file mode 100644 index 000000000..7922597d8 --- /dev/null +++ b/ore/app/ore/rest/FakeChannel.scala @@ -0,0 +1,20 @@ +package ore.rest + +import ore.models.project.{TagColor, Version} + +case class FakeChannel( + name: String, + color: TagColor, + isNonReviewed: Boolean +) +object FakeChannel { + + def fromVersion(version: Version): FakeChannel = { + val stability = version.tags.stability + FakeChannel( + stability.value.capitalize, + TagColor.Green, + stability != Version.Stability.Stable + ) + } +} diff --git a/ore/app/ore/rest/OreRestfulApiV1.scala b/ore/app/ore/rest/OreRestfulApiV1.scala index 2a411cbc7..b145d509a 100644 --- a/ore/app/ore/rest/OreRestfulApiV1.scala +++ b/ore/app/ore/rest/OreRestfulApiV1.scala @@ -67,7 +67,8 @@ trait OreRestfulApiV1 extends OreWrites { id <- preSearch t <- unsortedProjects if t._1.id.value == id - } yield t + (p, v) = t + } yield (p, v, FakeChannel.fromVersion(v)) json <- writeProjects(sortedProjects) } yield { toJson(json.map(_._2)) @@ -96,16 +97,11 @@ trait OreRestfulApiV1 extends OreWrites { } private def writeProjects( - projects: Seq[(Model[Project], Model[Version], Model[Channel])] + projects: Seq[(Model[Project], Model[Version], FakeChannel)] ): UIO[Seq[(Model[Project], JsObject)]] = { val projectIds = projects.map(_._1.id.value) - val versionIds = projects.map(_._2.id.value) for { - chans <- service.runDBIO(queryProjectChannels(projectIds).result).map(chans => chans.groupBy(_.projectId)) - vTags <- service.runDBIO(queryVersionTags(versionIds).result).map { p => - p.groupBy(_._1).view.mapValues(_.map(_._2)) - } members <- service.runDBIO(getMembers(projectIds).result).map(_.groupBy(_._1.projectId)) } yield { @@ -121,8 +117,8 @@ trait OreRestfulApiV1 extends OreWrites { "description" -> p.description, "href" -> s"/${p.ownerName}/${p.slug}", "members" -> writeMembers(members.getOrElse(p.id.value, Seq.empty)), - "channels" -> toJson(chans.getOrElse(p.id.value, Seq.empty).map(_.obj)), - "recommended" -> toJson(writeVersion(v, p, c, None, vTags.getOrElse(v.id.value, Seq.empty))), + "channels" -> toJson(Version.Stability.values.map(_.value.capitalize)), + "recommended" -> toJson(writeVersion(v, p, c, None)), "category" -> obj("title" -> p.category.title, "icon" -> p.category.icon), "views" -> 0, "downloads" -> 0, @@ -136,9 +132,8 @@ trait OreRestfulApiV1 extends OreWrites { def writeVersion( v: Model[Version], p: Project, - c: Channel, - author: Option[String], - tags: Seq[Model[VersionTag]] + c: FakeChannel, + author: Option[String] ): JsObject = { val dependencies: List[JsObject] = v.dependencies.map { dependency => obj("pluginId" -> dependency.pluginId, "version" -> dependency.version) @@ -155,40 +150,22 @@ trait OreRestfulApiV1 extends OreWrites { "staffApproved" -> v.reviewState.isChecked, "reviewState" -> v.reviewState.toString, "href" -> ("/" + v.url(p)), - "tags" -> tags.map(toJson(_)), + "tags" -> JsArray.empty, "downloads" -> 0, "description" -> v.description ) - lazy val jsonVisibility = obj( - "type" -> v.visibility.nameKey, - "css" -> v.visibility.cssClass - ) - - val withVisibility = if (v.visibility == Visibility.Public) json else json + ("visibility" -> jsonVisibility) - author.fold(withVisibility)(a => withVisibility + (("author", JsString(a)))) + author.fold(json)(a => json + (("author", JsString(a)))) } - private def queryProjectChannels(projectIds: Seq[DbRef[Project]]) = - TableQuery[ChannelTable].filter(_.projectId.inSetBind(projectIds)) - - private def queryVersionTags(versions: Seq[DbRef[Version]]) = - for { - v <- TableQuery[VersionTable] if v.id.inSetBind(versions) && v.visibility === (Visibility.Public: Visibility) - t <- TableQuery[VersionTagTable] if t.versionId === v.id - } yield (v.id, t) - private def queryProjectRV = { - //Gets around unused warning - def use[A](@unused a: A): Unit = () - for { - p <- TableQuery[ProjectTable] - v <- TableQuery[VersionTable] if p.recommendedVersionId === v.id - c <- TableQuery[ChannelTable] if v.channelId === c.id - _ = use(c) + hp <- TableQuery[ApiV1ProjectsTable] + p <- TableQuery[ProjectTable] if hp.id === p.id + v <- TableQuery[VersionTable] + if p.id === v.projectId && v.versionString === ((hp.promotedVersions ~> 0) +>> "version_string") if Visibility.isPublicFilter[ProjectTable](p) - } yield (p, v, c) + } yield (p, v) } /** @@ -199,11 +176,11 @@ trait OreRestfulApiV1 extends OreWrites { */ def getProject(pluginId: String): UIO[Option[JsValue]] = { val query = queryProjectRV.filter { - case (p, _, _) => p.pluginId === pluginId + case (p, _) => p.pluginId === pluginId } for { project <- service.runDBIO(query.result.headOption) - json <- writeProjects(project.toSeq) + json <- writeProjects(project.map(t => (t._1, t._2, FakeChannel.fromVersion(t._2))).toSeq) } yield { json.headOption.map(_._2) } @@ -225,17 +202,19 @@ trait OreRestfulApiV1 extends OreWrites { offset: Option[Int], onlyPublic: Boolean ): UIO[JsValue] = { - val filtered = channels - .map { chan => + val stabilityFilter = channels.map(_.split(",").view.flatMap(Version.Stability.withValueOpt).toSeq) + + val filtered = stabilityFilter + .map { stabilities => queryVersions(onlyPublic).filter { - case (_, _, _, c, _) => + case (_, v, _, _) => // Only allow versions in the specified channels or all if none specified - c.name.toLowerCase.inSetBind(chan.toLowerCase.split(",")) + v.stability.inSetBind(stabilities) } } .getOrElse(queryVersions(onlyPublic)) - .filter { case (p, _, _, _, _) => p.pluginId.toLowerCase === pluginId.toLowerCase } - .sortBy { case (_, v, _, _, _) => v.createdAt.desc } + .filter { case (p, _, _, _) => p.pluginId.toLowerCase === pluginId.toLowerCase } + .sortBy { case (_, v, _, _) => v.createdAt.desc } val maxLoad = this.config.ore.projects.initVersionLoad val lim = max(min(limit.getOrElse(maxLoad), maxLoad), 0) @@ -243,12 +222,11 @@ trait OreRestfulApiV1 extends OreWrites { val limited = filtered.drop(offset.getOrElse(0)).take(lim) for { - data <- service.runDBIO(limited.result) // Get Project Version Channel and AuthorName - vTags <- service.runDBIO(queryVersionTags(data.map(_._3)).result).map(_.groupBy(_._1).view.mapValues(_.map(_._2))) + data <- service.runDBIO(limited.result) // Get Project Version Channel and AuthorName } yield { val list = data.map { - case (p, v, vId, c, uName) => - writeVersion(v, p, c, uName, vTags.getOrElse(vId, Seq.empty)) + case (p, v, _, uName) => + writeVersion(v, p, FakeChannel.fromVersion(v), uName) } toJson(list) } @@ -264,18 +242,17 @@ trait OreRestfulApiV1 extends OreWrites { def getVersion(pluginId: String, name: String): UIO[Option[JsValue]] = { val filtered = queryVersions().filter { - case (p, v, _, _, _) => + case (p, v, _, _) => p.pluginId.toLowerCase === pluginId.toLowerCase && v.versionString.toLowerCase === name.toLowerCase } for { - data <- service.runDBIO(filtered.result.headOption) // Get Project Version Channel and AuthorName - tags <- service.runDBIO(queryVersionTags(data.map(_._3).toSeq).result).map(_.map(_._2)) // Get Tags + data <- service.runDBIO(filtered.result.headOption) // Get Project Version Channel and AuthorName } yield { data.map { - case (p, v, _, c, uName) => - writeVersion(v, p, c, uName, tags) + case (p, v, _, uName) => + writeVersion(v, p, FakeChannel.fromVersion(v), uName) } } } @@ -284,11 +261,10 @@ trait OreRestfulApiV1 extends OreWrites { for { p <- TableQuery[ProjectTable] (v, u) <- TableQuery[VersionTable].joinLeft(TableQuery[UserTable]).on(_.authorId === _.id) - c <- TableQuery[ChannelTable] - if v.channelId === c.id && p.id === v.projectId && (if (onlyPublic) - v.visibility === (Visibility.Public: Visibility) - else true) - } yield (p, v, v.id, c, u.map(_.name)) + if p.id === v.projectId && (if (onlyPublic) + v.visibility === (Visibility.Public: Visibility) + else true) + } yield (p, v, v.id, u.map(_.name)) /** * Returns a list of pages for the specified project. @@ -351,13 +327,13 @@ trait OreRestfulApiV1 extends OreWrites { implicit def config: OreConfig = this.config val query = queryProjectRV.filter { - case (p, _, _) => p.ownerId.inSetBind(userList.map(_.id.value)) // query all projects with given users + case (p, _) => p.ownerId.inSetBind(userList.map(_.id.value)) // query all projects with given users } for { allProjects <- service.runDBIO(query.result) stars <- service.runDBIO(queryStars(userList).result).map(_.groupBy(_._1).view.mapValues(_.map(_._2))) - jsonProjects <- writeProjects(allProjects) + jsonProjects <- writeProjects(allProjects.map(t => (t._1, t._2, FakeChannel.fromVersion(t._2)))) userGlobalRoles <- ZIO.foreachParN(config.performance.nioBlockingFibers)(userList)(_.globalRoles.allFromParent) } yield { val projectsByUser = jsonProjects.groupBy(_._1.ownerId).view.mapValues(_.map(_._2)) @@ -402,21 +378,9 @@ trait OreRestfulApiV1 extends OreWrites { */ def getTags( pluginId: String, - version: String - )(implicit projectBase: ProjectBase[UIO]): OptionT[UIO, JsValue] = { - OptionT(projectBase.withPluginId(pluginId)).flatMap { project => - project - .versions(ModelView.now(Version)) - .find(v => - v.versionString.toLowerCase === version.toLowerCase && v.visibility === (Visibility.Public: Visibility) - ) - .semiflatMap { v => - service.runDBIO(v.tags(ModelView.raw(VersionTag)).result).map { tags => - obj("pluginId" -> pluginId, "version" -> version, "tags" -> tags.map(toJson(_))): JsValue - } - } - } - } + @unused version: String + )(implicit projectBase: ProjectBase[UIO]): OptionT[UIO, JsValue] = + OptionT(projectBase.withPluginId(pluginId)).map(_ => JsArray.empty) /** * Get the Tag Color information from an ID diff --git a/ore/app/ore/rest/OreWrites.scala b/ore/app/ore/rest/OreWrites.scala index 0baef3412..5bb1fe018 100644 --- a/ore/app/ore/rest/OreWrites.scala +++ b/ore/app/ore/rest/OreWrites.scala @@ -30,18 +30,8 @@ trait OreWrites { "slug" -> page.slug ) - implicit val channelWrites: Writes[Channel] = (channel: Channel) => - obj("name" -> channel.name, "color" -> channel.color.hex, "nonReviewed" -> channel.isNonReviewed) - - implicit val tagWrites: Writes[Model[VersionTag]] = (tag: Model[VersionTag]) => { - obj( - "id" -> tag.id.value, - "name" -> tag.name, - "data" -> tag.data, - "backgroundColor" -> tag.color.background, - "foregroundColor" -> tag.color.foreground - ) - } + implicit val channelWrites: Writes[FakeChannel] = (channel: FakeChannel) => + obj("name" -> channel.name, "color" -> channel.color.background, "nonReviewed" -> channel.isNonReviewed) implicit val tagColorWrites: Writes[TagColor] = (tagColor: TagColor) => { obj( diff --git a/ore/app/util/StringFormatterUtils.scala b/ore/app/util/StringFormatterUtils.scala index e3a82a811..956055c9b 100644 --- a/ore/app/util/StringFormatterUtils.scala +++ b/ore/app/util/StringFormatterUtils.scala @@ -1,6 +1,6 @@ package util -import java.time.{Instant, OffsetDateTime} +import java.time.OffsetDateTime import play.api.i18n.Messages diff --git a/ore/app/views/home.scala.html b/ore/app/views/home.scala.html index b1ec7a8b6..27f2c731a 100644 --- a/ore/app/views/home.scala.html +++ b/ore/app/views/home.scala.html @@ -2,30 +2,16 @@ The main entry point of Ore. This page displays a list of Projects that can be sorted according to different criteria. *@ -@import scala.util.Random - @import controllers.sugar.Requests.OreRequest @import ore.OreConfig -@import ore.Sponsor @()(implicit messages: Messages, flash: Flash, request: OreRequest[_], config: OreConfig, assetsFinder: AssetsFinder) -@randomSponsor = @{ - val sponsors = config.sponge.sponsors - - val totalWeight = sponsors.map(_.weight).sum - var randomNumber = Random.nextInt(totalWeight) - - sponsors.find { sponsor => - randomNumber < sponsor.weight || { randomNumber -= sponsor.weight; false } - }.getOrElse(sponsors.last) -} - @scripts = { - + } @stylesheets = { - + } @meta = { @@ -36,36 +22,7 @@ } -@layout.base(messages("general.title"), scripts, additionalMeta = meta, additionalStyling = stylesheets) { - - -
-
-
-
- -
-
Ore
-
A Minecraft package repository
-
-
-
- -
-
+@layout.base(messages("general.title"), scripts, additionalMeta = meta, additionalStyling = stylesheets, empty = true) {
} diff --git a/ore/app/views/projects/admin/flags.scala.html b/ore/app/views/projects/admin/flags.scala.html index ae344ae99..312ac85da 100644 --- a/ore/app/views/projects/admin/flags.scala.html +++ b/ore/app/views/projects/admin/flags.scala.html @@ -12,7 +12,7 @@
-

@messages("project.flag.plural") for @p.project.ownerName/@p.project.slug

+

@messages("project.flag.plural") for @p.project.ownerName/@p.project.slug

diff --git a/ore/app/views/projects/admin/notes.scala.html b/ore/app/views/projects/admin/notes.scala.html index d9a249f6b..89aeda9b3 100644 --- a/ore/app/views/projects/admin/notes.scala.html +++ b/ore/app/views/projects/admin/notes.scala.html @@ -21,7 +21,7 @@
-

@messages("notes") for @project.ownerName/@project.slug

+

@messages("notes") for @project.ownerName/@project.slug

diff --git a/ore/app/views/projects/channels/helper/modalManage.scala.html b/ore/app/views/projects/channels/helper/modalManage.scala.html deleted file mode 100644 index b4f177f49..000000000 --- a/ore/app/views/projects/channels/helper/modalManage.scala.html +++ /dev/null @@ -1,52 +0,0 @@ -@import ore.OreConfig -@import views.html.helper.{CSRF, form} -@()(implicit messages: Messages, config: OreConfig, request: Request[_]) - - diff --git a/ore/app/views/projects/channels/helper/popoverColorPicker.scala.html b/ore/app/views/projects/channels/helper/popoverColorPicker.scala.html deleted file mode 100644 index eec637e0c..000000000 --- a/ore/app/views/projects/channels/helper/popoverColorPicker.scala.html +++ /dev/null @@ -1,48 +0,0 @@ -@import ore.models.project.Channel.Colors -@() - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ore/app/views/projects/channels/list.scala.html b/ore/app/views/projects/channels/list.scala.html deleted file mode 100644 index 181e87d67..000000000 --- a/ore/app/views/projects/channels/list.scala.html +++ /dev/null @@ -1,134 +0,0 @@ -@import controllers.sugar.Requests.OreRequest -@import models.viewhelper.ProjectData -@import ore.OreConfig -@import ore.db.Model -@import ore.models.project.Channel -@import views.html.helper.{CSPNonce, CSRF, form} -@(p: ProjectData, channels: Seq[(Model[Channel], Int)])(implicit messages: Messages, flash: Flash, request: OreRequest[_], config: OreConfig, assetsFinder: AssetsFinder) - -@channelRoutes = @{controllers.project.routes.Channels} -@versionRoutes = @{controllers.project.routes.Versions} - -@scripts = { - - -} - -@layout.base(messages("channel.list.title", p.project.ownerName, p.project.slug), scripts) { - -
-
-
-
-

@messages("channel.list.title")

-
-
-

- @messages("channel.list.description") -

- - - - @channels.map { case (channel, versions) => - - - - @if(channels.size > 1) { - - } - - - } - -
-
@channel.name
-
-
Edit
-
-
0) { - id="channel-delete-@channel.id" data-toggle="modal" - data-target="#modal-delete"> - } else { - id="channel-delete-@channel.id" data-channel-delete="safe-delete" - data-channel-id="@channel.id"> - - @form(action = channelRoutes.delete( - p.project.ownerName, p.project.slug, channel.name), - Symbol("id") -> s"form-delete-${channel.id}", - Symbol("class") -> "form-channel-delete") { - @CSRF.formField - } - } - Delete -
-
- - - - = config.ore.projects.maxChannels) { - disabled data-toggle="tooltip" data-placement="left" - title="@messages("channel.edit.maxReached")" - } else { - data-toggle="modal" data-target="#channel-settings" - } - > - - - @projects.channels.helper.modalManage() -
-
-
-
- - - - - - -