diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 443f400b3..1e999c1ef 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -9,20 +9,24 @@ import play.api.inject.ApplicationLifecycle import play.api.mvc._ import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} -import controllers.sugar.CircePlayController -import controllers.sugar.Requests.{ApiAuthInfo, ApiRequest} +import controllers.sugar.{CircePlayController, ResolvedAPIScope} +import controllers.sugar.Requests.ApiRequest import controllers.{OreBaseController, OreControllerComponents} import db.impl.query.APIV2Queries import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.{OrganizationTable, ProjectTable, UserTable} import ore.models.api.ApiSession +import ore.models.project.Webhook import ore.permission.Permission -import ore.permission.scope.{GlobalScope, OrganizationScope, ProjectScope, Scope} +import ore.WebhookJobAdder +import ackcord.data.OutgoingEmbed import akka.http.scaladsl.model.ErrorInfo import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials} import cats.data.NonEmptyList import cats.syntax.all._ +import io.circe.Encoder +import io.circe.syntax._ import zio.interop.catz._ import zio.{IO, Task, UIO, ZIO} @@ -83,39 +87,37 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } yield res } - def apiAction(scope: APIScope): 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(_.toResult) - token <- ZIO - .fromOption(creds.params.get("session")) - .orElseFail(unAuth("No session specified")) - info <- service - .runDbCon(APIV2Queries.getApiAuthInfo(token).option) - .get - .orElseFail(unAuth("Invalid session")) - scopePerms <- { - val res: IO[Result, Permission] = - apiScopeToRealScope(scope).flatMap(info.permissionIn(_)).orElseFail(NotFound) - res - } - res <- { - if (info.expires.isBefore(OffsetDateTime.now())) { - service.deleteWhere(ApiSession)(_.token === token) *> IO.fail(unAuth("Api session expired")) - } else ZIO.succeed(ApiRequest(info, scopePerms, request)) - } - } yield res - - zioToFuture(authRequest.either) + def apiAction[S <: ResolvedAPIScope](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(APIV2Queries.getApiAuthInfo(token).option) + .get + .orElseFail(unAuth("Invalid session")) + resolvedScope <- apiScopeToResolvedScope(scope).orElseFail(NotFound) + scopePerms <- info.permissionIn(resolvedScope) + res <- { + if (info.expires.isBefore(OffsetDateTime.now())) { + service.deleteWhere(ApiSession)(_.token === token) *> IO.fail(unAuth("Api session expired")) + } else ZIO.succeed(ApiRequest(info, scopePerms, resolvedScope, request)) + } + } yield res + + zioToFuture(authRequest.either) + } } - } - def apiScopeToRealScope(scope: APIScope): IO[Unit, Scope] = scope match { - case APIScope.GlobalScope => UIO.succeed(GlobalScope) + def apiScopeToResolvedScope[S <: ResolvedAPIScope](scope: APIScope[S]): IO[Unit, S] = scope match { + case APIScope.GlobalScope => UIO.succeed(ResolvedAPIScope.GlobalScope.asInstanceOf[S]) case APIScope.ProjectScope(projectOwner, projectSlug) => service .runDBIO( @@ -127,7 +129,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( ) .get .orElseFail(()) - .map(ProjectScope) + .map(ResolvedAPIScope.ProjectScope(projectOwner, projectSlug, _).asInstanceOf[S]) case APIScope.OrganizationScope(organizationName) => val q = for { u <- TableQuery[UserTable] @@ -139,14 +141,14 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( .runDBIO(q.result.headOption) .get .orElseFail(()) - .map(OrganizationScope) + .map(ResolvedAPIScope.OrganizationScope(organizationName, _).asInstanceOf[S]) } def createApiScope( projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String] - ): Either[Result, APIScope] = { + ): Either[Result, APIScope[_ <: ResolvedAPIScope]] = { val projectOwnerName = projectOwner.zip(projectSlug) if ((projectOwner.isDefined || projectSlug.isDefined) && projectOwnerName.isEmpty) { @@ -162,32 +164,23 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def permissionsInApiScope( - projectOwner: Option[String], - projectSlug: Option[String], - organizationName: Option[String] - )( - implicit request: ApiRequest[_] - ): IO[Result, (APIScope, Permission)] = - for { - apiScope <- ZIO.fromEither(createApiScope(projectOwner, projectSlug, organizationName)) - scope <- apiScopeToRealScope(apiScope).orElseFail(NotFound) - perms <- request.permissionIn(scope) - } yield (apiScope, perms) - - def permApiAction(perms: Permission): ActionFilter[ApiRequest] = new ActionFilter[ApiRequest] { - override protected def executionContext: ExecutionContext = ec + def permApiAction[S <: ResolvedAPIScope](perms: Permission): ActionFilter[ApiRequest[S, *]] = + new ActionFilter[ApiRequest[S, *]] { + override protected def executionContext: ExecutionContext = ec - override protected def filter[A](request: ApiRequest[A]): Future[Option[Result]] = - if (request.scopePermission.has(perms)) Future.successful(None) - else Future.successful(Some(Forbidden)) - } + 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: ActionFunction[ApiRequest, ApiRequest] = - new ActionFunction[ApiRequest, ApiRequest] { + def cachingAction[S <: ResolvedAPIScope]: ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] = + new ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] { override protected def executionContext: ExecutionContext = ec - override def invokeBlock[A](request: ApiRequest[A], block: ApiRequest[A] => Future[Result]): Future[Result] = { + override def invokeBlock[A]( + request: ApiRequest[S, A], + block: ApiRequest[S, A] => Future[Result] + ): Future[Result] = { import scalacache.modes.scalaFuture._ require(request.method == "GET") @@ -204,9 +197,33 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def ApiAction(perms: Permission, scope: APIScope): ActionBuilder[ApiRequest, AnyContent] = + def ApiAction[S <: ResolvedAPIScope]( + perms: Permission, + scope: APIScope[S] + ): ActionBuilder[ApiRequest[S, *], AnyContent] = Action.andThen(apiAction(scope)).andThen(permApiAction(perms)) - def CachingApiAction(perms: Permission, scope: APIScope): ActionBuilder[ApiRequest, AnyContent] = + def CachingApiAction[S <: ResolvedAPIScope]( + perms: Permission, + scope: APIScope[S] + ): ActionBuilder[ApiRequest[S, *], AnyContent] = ApiAction(perms, scope).andThen(cachingAction) + + def addWebhookJob[A: Encoder]( + webhookEvent: Webhook.WebhookEventType, + data: A, + discordData: OutgoingEmbed + )( + implicit request: ApiRequest[ResolvedAPIScope.ProjectScope, _] + ): UIO[Unit] = { + ackcord.requests.CreateMessage + WebhookJobAdder.add( + request.scope.id, + request.scope.projectOwner, + request.scope.projectSlug, + webhookEvent, + data.asJson.noSpaces, + discordData + ) + } } diff --git a/apiV2/app/controllers/apiv2/Organizations.scala b/apiV2/app/controllers/apiv2/Organizations.scala index a4eaa290f..57801c0dd 100644 --- a/apiV2/app/controllers/apiv2/Organizations.scala +++ b/apiV2/app/controllers/apiv2/Organizations.scala @@ -15,7 +15,6 @@ import ore.permission.Permission import io.circe.syntax._ import zio.interop.catz._ -import zio._ class Organizations( val errorHandler: HttpErrorHandler, @@ -33,20 +32,22 @@ class Organizations( def showMembers(organization: String, limit: Option[Long], offset: Long): Action[AnyContent] = CachingApiAction(Permission.ViewPublicInfo, APIScope.OrganizationScope(organization)).asyncF { implicit r => - Members.membersAction(APIV2Queries.orgaMembers(organization, _, _), limit, offset) + Members.membersAction(APIV2Queries.orgaMembers(r.scope.id, _, _), limit, offset).map(Ok(_)) } def updateMembers(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 = organizations.withName(organization).someOrFail(NotFound), - allowOrgMembers = false, - getMembersQuery = APIV2Queries.orgaMembers(organization, _, _), - createRole = OrganizationUserRole(_, _, _), - roleCompanion = OrganizationUserRole, - notificationType = NotificationType.OrganizationInvite, - notificationLocalization = "notification.organization.invite" - ) + for { + res <- Members.updateMembers[Organization, OrganizationUserRole, OrganizationRoleTable]( + getSubject = r.organization, + allowOrgMembers = false, + getMembersQuery = APIV2Queries.orgaMembers(r.scope.id, _, _), + createRole = OrganizationUserRole(_, _, _), + roleCompanion = OrganizationUserRole, + notificationType = NotificationType.OrganizationInvite, + notificationLocalization = "notification.organization.invite" + ) + } yield Ok(res) } } diff --git a/apiV2/app/controllers/apiv2/Pages.scala b/apiV2/app/controllers/apiv2/Pages.scala index 66f39d2ae..b71ee3bf6 100644 --- a/apiV2/app/controllers/apiv2/Pages.scala +++ b/apiV2/app/controllers/apiv2/Pages.scala @@ -6,12 +6,14 @@ import play.api.mvc.{Action, AnyContent} import controllers.OreControllerComponents import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} +import controllers.sugar.Requests.ApiRequest +import controllers.sugar.ResolvedAPIScope import db.impl.query.APIV2Queries 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.models.project.{Page, Project, Webhook} import ore.permission.Permission import ore.util.StringUtils import util.PatchDecoder @@ -33,63 +35,78 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) ) extends AbstractApiV2Controller(lifecycle) { def showPages(projectOwner: String, projectSlug: String): Action[AnyContent] = - CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { - service.runDbCon(APIV2Queries.pageList(projectOwner, projectSlug).to[Vector]).flatMap { pages => + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service.runDbCon(APIV2Queries.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._3, t._4, t._5))))) + 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[ResolvedAPIScope.ProjectScope, _] + ): ZIO[Any, Option[Nothing], (DbRef[Page], String, Option[String])] = + service + .runDbCon(APIV2Queries.getPage(request.scope.id, page).option) + .get + + private def getPage( + page: String + )( + implicit request: ApiRequest[ResolvedAPIScope.ProjectScope, _] + ): ZIO[Any, Status, (DbRef[Page], String, Option[String])] = + getPage(page).orElseFail(NotFound) + def showPageAction(projectOwner: String, projectSlug: String, page: String): Action[AnyContent] = - CachingApiAction(Permission.ViewPublicInfo, APIScope.GlobalScope).asyncF { - service.runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, page).option).get.orElseFail(NotFound).map { - case (_, _, name, contents) => + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service.runDbCon(APIV2Queries.getPage(request.scope.id, page).option).get.orElseFail(NotFound).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]) { c => - val newName = StringUtils.compact(c.body.name) - val content = c.body.content - - val pageArr = page.split("/") - val pageInit = pageArr.init.mkString("/") - val slug = StringUtils.slugify(pageArr.last) //TODO: Check ASCII - - val updateExisting = - service.runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, page).option).get.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))) - } + .asyncF(parseCirce.decodeJson[APIV2.Page]) { implicit r => + val newName = StringUtils.compact(r.body.name) + val content = r.body.content + + val pageArr = page.split("/").toIndexedSeq + val pageParent = pageArr.init.mkString("/") + val slug = StringUtils.slugify(pageArr.last) //TODO: Check ASCII + + val updateExisting = getPageOpt(page).flatMap { + case (id, _, _) => + addWebhookJob( + Webhook.WebhookEventType.PageUpdated, + APIV2.PageUpdateWithSlug(newName, pageArr, pageArr, content.isDefined, content), + ??? + ) *> service + .runDBIO( + TableQuery[PageTable].filter(_.id === id).map(p => (p.name, p.contents)).update((newName, content)) + ) + .as(Ok(APIV2.Page(newName, content))) + } - def insertNewPage(projectId: DbRef[Project], parentId: Option[DbRef[Page]]) = - service - .insert(Page(projectId, parentId, newName, slug, isDeletable = true, content)) + def insertNewPage(parentId: Option[DbRef[Page]]) = { + addWebhookJob( + Webhook.WebhookEventType.PageCreated, + APIV2.PageWithSlug(newName, pageArr, content.isDefined, content), + ??? + ) *> service + .insert(Page(r.scope.id, parentId, newName, slug, isDeletable = true, content)) .as(Created(APIV2.Page(newName, content))) + } val createNew = if (page.contains("/")) { - service - .runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, pageInit).option) - .get - .orElseFail(NotFound) - .flatMap { - case (projectId, parentId, _, _) => - insertNewPage(projectId, Some(parentId)) - } + getPage(pageParent).flatMap { + case (parentId, _, _) => + insertNewPage(Some(parentId)) + } } else { - projects - .withSlug(projectOwner, projectSlug) - .get - .orElseFail(NotFound) - .map(_.id) - .flatMap(insertNewPage(_, None)) + insertNewPage(None) } if (page == Page.homeName && content.fold(0)(_.length) < Page.minLength) @@ -109,29 +126,42 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) res match { case Validated.Valid(a) => - val newName = a.copy[Option]( + val edits = a.copy[Option]( name = a.name.map(StringUtils.compact) ) - val slug = newName.name.map(StringUtils.slugify) + val slug = edits.name.map(StringUtils.slugify) - val oldPage = - service.runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, page).option).get.orElseFail(NotFound) - val newParent = newName.parent - .map( - _.map(p => service.runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, p).option).get.map(_._2)).sequence - ) - .sequence - .orElseFail(BadRequest(ApiError("Unknown parent"))) + val oldPage = getPage(page) + val newParent = + edits.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(APIV2Queries.patchPage(newName, slug, id, parentId).run) - .as(Ok(APIV2.Page(newName.name.getOrElse(name), newName.content.getOrElse(contents)))) + case ((id, name, contents), parentId) => + val oldSlug = page.split("/").toIndexedSeq + val oldParent = oldSlug.dropRight(1) + val newParent = edits.parent.map(_.fold(IndexedSeq.empty[String])(_.split("/").toIndexedSeq)) + + addWebhookJob( + Webhook.WebhookEventType.PageUpdated, + APIV2.PageUpdateWithSlug( + edits.name.getOrElse(name), + oldSlug, + newParent.getOrElse(oldParent.toList) :+ slug.getOrElse(oldSlug.last), + edits.content.getOrElse(contents).isDefined, + edits.content.getOrElse(contents) + ), + ??? + ) *> + service + .runDbCon(APIV2Queries.patchPage(edits, slug, id, parentId).run) + .as(Ok(APIV2.Page(edits.name.getOrElse(name), edits.content.getOrElse(contents)))) } - if (newName.content.flatten.fold(0)(_.length) > Page.maxLengthPage) + if (edits.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)))) @@ -139,21 +169,20 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) } def deletePage(projectOwner: String, projectSlug: String, page: String): Action[AnyContent] = - ApiAction(Permission.EditPage, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { - service - .runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, page).option) - .get - .orElseFail(NotFound) + ApiAction(Permission.EditPage, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit request => + getPage(page) .flatMap { - case (_, id, _, _) => + 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 + .flatMap { + case 0 => ZIO.succeed(BadRequest(ApiError("Page not deletable"))) + case _ => + addWebhookJob(Webhook.WebhookEventType.PageUpdated, APIV2.PageSlug(page.split("/").toIndexedSeq), ???) + .as(NoContent) } } } diff --git a/apiV2/app/controllers/apiv2/Permissions.scala b/apiV2/app/controllers/apiv2/Permissions.scala index 536e3a6ff..448ea05c8 100644 --- a/apiV2/app/controllers/apiv2/Permissions.scala +++ b/apiV2/app/controllers/apiv2/Permissions.scala @@ -22,18 +22,24 @@ class Permissions( projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String] - ): Action[AnyContent] = - CachingApiAction(Permission.None, APIScope.GlobalScope).asyncF { implicit request => - permissionsInApiScope(projectOwner, projectSlug, organizationName).map { - case (scope, perms) => + ): Action[AnyContent] = { + createApiScope(projectOwner, projectSlug, organizationName) match { + case Right(scope) => + CachingApiAction(Permission.None, scope) { implicit request => Ok( KeyPermissions( scope.tpe, - perms.toNamedSeq.toList + request.scopePermission.toNamedSeq.toList ) ) - } + } + case Left(error) => + //We still run auth and such + CachingApiAction(Permission.None, APIScope.GlobalScope) { + error + } } + } def has( checkPermissions: Seq[NamedPermission], @@ -42,13 +48,19 @@ class Permissions( organizationName: Option[String] )( check: (Seq[Permission], Permission) => Boolean - ): Action[AnyContent] = - CachingApiAction(Permission.None, APIScope.GlobalScope).asyncF { implicit request => - permissionsInApiScope(projectOwner, projectSlug, organizationName).map { - case (scope, perms) => - Ok(PermissionCheck(scope.tpe, check(checkPermissions.map(_.permission), perms))) - } + ): 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], @@ -56,7 +68,8 @@ class Permissions( projectSlug: Option[String], organizationName: Option[String] ): Action[AnyContent] = - has(permissions, projectOwner, projectSlug, organizationName)((seq, perm) => seq.forall(perm.has(_))) + //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], diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 3ad62532a..8f3640f0d 100644 --- a/apiV2/app/controllers/apiv2/Projects.scala +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -3,6 +3,7 @@ package controllers.apiv2 import java.net.URLEncoder import java.time.LocalDate import java.time.format.DateTimeParseException +import java.util.UUID import play.api.http.HttpErrorHandler import play.api.i18n.Lang @@ -12,6 +13,7 @@ import play.api.mvc.{Action, AnyContent, RequestHeader, Result} import controllers.OreControllerComponents import controllers.apiv2.helpers._ import controllers.sugar.Requests.ApiRequest +import controllers.sugar.ResolvedAPIScope import db.impl.query.APIV2Queries import models.protocols.APIV2 import models.querymodels.APIV2ProjectStatsQuery @@ -20,10 +22,13 @@ 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.db.access.ModelView +import ore.db.impl.schema.{ProjectRoleTable, WebhookTable} +import ore.db.impl.OrePostgresDriver.api._ import ore.models.Job +import ore.models.project.Webhook.WebhookEventType import ore.models.project.factory.{ProjectFactory, ProjectTemplate} -import ore.models.project.{Project, ProjectSortingStrategy, Version} +import ore.models.project.{Project, ProjectSortingStrategy, Version, Webhook => ModelWebhook} import ore.models.user.role.ProjectUserRole import ore.models.user.{LoggedActionProject, LoggedActionType} import ore.permission.Permission @@ -31,16 +36,18 @@ import ore.util.{OreMDC, StringUtils} import util.syntax._ import util.{PartialUtils, PatchDecoder, UserActionLogger} +import akka.http.scaladsl.model.Uri import cats.data.{NonEmptyList, Validated, ValidatedNel} import cats.syntax.all._ import io.circe._ import io.circe.derivation.annotations.SnakeCaseJsonCodec import io.circe.syntax._ import squeal.category._ +import squeal.category.syntax.all._ import squeal.category.macros.Derive import zio.blocking.Blocking import zio.interop.catz._ -import zio.{Task, UIO, ZIO} +import zio.{IO, Task, UIO, ZIO} class Projects( factory: ProjectFactory, @@ -180,14 +187,14 @@ class Projects( ) ) ) - ) + ).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 => - apiScopeToRealScope(APIScope.ProjectScope(projectOwner, projectSlug)) + apiScopeToResolvedScope(APIScope.ProjectScope(projectOwner, projectSlug)) .mapError[Either[Unit, Unit]](_ => Right(())) .flatMap(request.permissionIn(_)) .filterOrFail(_.has(Permission.ViewPublicInfo))(Left(())) @@ -210,9 +217,9 @@ class Projects( } def showProjectDescription(projectOwner: String, projectSlug: String): Action[AnyContent] = - CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { - service.runDbCon(APIV2Queries.getPage(projectOwner, projectSlug, "Home").option).get.orElseFail(NotFound).map { - case (_, _, _, contents) => + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => + service.runDbCon(APIV2Queries.getPage(request.scope.id, "Home").option).get.orElseFail(NotFound).map { + case (_, _, contents) => Ok(Json.obj("description" := contents)) } } @@ -239,19 +246,15 @@ class Projects( 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 = projects - .withSlug(projectOwner, projectSlug) - .get - .orDieWith(_ => new Exception("impossible")) - .flatMap(projects.rename(_, newName).absolve) - .mapError(e => BadRequest(ApiError(e))) + 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 <- projects.withSlug(projectOwner, projectSlug).someOrFail(NotFound) + project <- request.project user <- users.withName(newOwner)(OreMDC.NoMDC).value.someOrFail(NotFound) userRole <- project .memberships[Task, ProjectUserRole, ProjectRoleTable] @@ -272,7 +275,7 @@ class Projects( val newSlug = edits.name.fold(projectSlug)(StringUtils.slugify) //We need to be careful and use the new name and slug if they were changed - val update = service.runDbCon(APIV2Queries.updateProject(newOwner, newSlug, withoutNameAndOwner).run) + val update = service.runDbCon(APIV2Queries.updateProject(request.scope.id, withoutNameAndOwner).run) //We need two queries two queries as we use the generic update function val get = service @@ -303,21 +306,35 @@ class Projects( def showMembers(projectOwner: String, projectSlug: String, limit: Option[Long], offset: Long): Action[AnyContent] = CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => - Members.membersAction(APIV2Queries.projectMembers(projectOwner, projectSlug, _, _), limit, offset) + Members.membersAction(APIV2Queries.projectMembers(r.scope.id, _, _), limit, offset).map(Ok(_)) } def updateMembers(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 = projects.withSlug(projectOwner, projectSlug).someOrFail(NotFound), - allowOrgMembers = true, - getMembersQuery = APIV2Queries.projectMembers(projectOwner, projectSlug, _, _), - createRole = ProjectUserRole(_, _, _), - roleCompanion = ProjectUserRole, - notificationType = NotificationType.ProjectInvite, - notificationLocalization = "notification.project.invite" - ) + for { + oldMembersRaw <- service.runDbCon( + APIV2Queries.projectMembers(r.scope.id, 25, 0).to[List] + ) + oldMembers = { + if (r.scopePermission.has(Permission.ManageSubjectMembers)) oldMembersRaw + else oldMembersRaw.filter(_.role.isAccepted) + } + newMembers <- Members.updateMembers[Project, ProjectUserRole, ProjectRoleTable]( + getSubject = r.project, + allowOrgMembers = true, + getMembersQuery = APIV2Queries.projectMembers(r.scope.id, _, _), + createRole = ProjectUserRole(_, _, _), + roleCompanion = ProjectUserRole, + notificationType = NotificationType.ProjectInvite, + notificationLocalization = "notification.project.invite" + ) + _ <- addWebhookJob( + WebhookEventType.MemberChanged, + APIV2.MembersUpdate(oldMembers, newMembers.toList), + ??? + ) + } yield Ok(newMembers) } def showProjectStats( @@ -326,7 +343,7 @@ class Projects( fromDateString: String, toDateString: String ): Action[AnyContent] = - CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => import Ordering.Implicits._ def parseDate(dateStr: String) = @@ -342,7 +359,7 @@ class Projects( _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) res <- service.runDbCon( APIV2Queries - .projectStats(projectOwner, projectSlug, fromDate, toDate) + .projectStats(request.scope.id, fromDate, toDate) .to[Vector] .map(APIV2ProjectStatsQuery.asProtocol) ) @@ -352,7 +369,7 @@ class Projects( def setProjectVisibility(projectOwner: String, projectSlug: String): Action[EditVisibility] = ApiAction(Permission.None, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[EditVisibility]) { implicit request => - projects.withSlug(projectOwner, projectSlug).someOrFail(NotFound).flatMap { project => + request.project.flatMap { project => request.body.process( project, request.user.get.id, @@ -360,6 +377,8 @@ class Projects( Permission.DeleteProject, service.insert(Job.UpdateDiscourseProjectTopic.newJob(project.id).toJob).unit, doHardDeleteProject, + (_, _) => UIO.unit, + UIO.unit, (newV, oldV) => UserActionLogger .logApi( @@ -377,7 +396,7 @@ class Projects( def projectData(projectOwner: String, projectSlug: String): Action[AnyContent] = ApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => for { - project <- projects.withSlug(projectOwner, projectSlug).get.orElseFail(NotFound) + project <- r.project data <- ProjectData.of[ZIO[Blocking, Throwable, *]](project).orDie } yield Ok( Json.obj( @@ -393,7 +412,9 @@ class Projects( ) } - private def doHardDeleteProject(project: Model[Project])(implicit request: ApiRequest[_]): UIO[Unit] = { + private def doHardDeleteProject( + project: Model[Project] + )(implicit request: ApiRequest[_ <: ResolvedAPIScope, _]): UIO[Unit] = { projects.delete(project).unit <* UserActionLogger.logApiOption( request, LoggedActionType.ProjectVisibilityChange, @@ -405,25 +426,18 @@ class Projects( def hardDeleteProject(projectOwner: String, projectSlug: String): Action[AnyContent] = ApiAction(Permission.HardDeleteProject, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit r => - projects - .withSlug(projectOwner, projectSlug) - .someOrFail(NotFound) - .flatMap(doHardDeleteProject(_)) - .as(NoContent) + r.project.flatMap(doHardDeleteProject(_)).as(NoContent) } def editDiscourseSettings(projectOwner: String, projectSlug: String): Action[Projects.DiscourseModifyTopicSettings] = ApiAction(Permission.EditAdminSettings, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[Projects.DiscourseModifyTopicSettings]) { implicit request => - projects - .withSlug(projectOwner, projectSlug) - .someOrFail(NotFound) - .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) - } + 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 => @@ -445,7 +459,8 @@ class Projects( println(s"/api/v2/projects/$urlOwner/$urlSlug/$path") Redirect( s"/api/v2/projects/$urlOwner/$urlSlug/$path", - request.queryString ++ Map("ore-dont-pluginid-redirect" -> Seq("true")) + request.queryString ++ Map("ore-dont-pluginid-redirect" -> Seq("true")), + status = TEMPORARY_REDIRECT ) } .orElseFail(NotFound) diff --git a/apiV2/app/controllers/apiv2/Versions.scala b/apiV2/app/controllers/apiv2/Versions.scala index 755137c6f..3cefd927d 100644 --- a/apiV2/app/controllers/apiv2/Versions.scala +++ b/apiV2/app/controllers/apiv2/Versions.scala @@ -15,6 +15,7 @@ import play.api.mvc.{Action, AnyContent, MultipartFormData, Result} import controllers.OreControllerComponents import controllers.apiv2.helpers._ import controllers.sugar.Requests.ApiRequest +import controllers.sugar.ResolvedAPIScope import db.impl.query.APIV2Queries import models.protocols.APIV2 import models.querymodels.{APIV2QueryVersion, APIV2VersionStatsQuery} @@ -75,8 +76,7 @@ class Versions( val getVersions = APIV2Queries .versionQuery( - projectOwner, - projectSlug, + request.scope.id, None, parsedPlatforms, stability.toList, @@ -90,8 +90,7 @@ class Versions( val countVersions = APIV2Queries .versionCountQuery( - projectOwner, - projectSlug, + request.scope.id, parsedPlatforms, stability.toList, releaseType.toList, @@ -117,8 +116,7 @@ class Versions( .runDbCon( APIV2Queries .singleVersionQuery( - projectOwner, - projectSlug, + request.scope.id, name, request.globalPermissions.has(Permission.SeeHidden), request.user.map(_.id) @@ -193,8 +191,7 @@ class Versions( res match { case Validated.Valid(WriterT((warnings, (version, platforms)))) => val versionIdQuery = for { - p <- TableQuery[ProjectTable] if p.ownerName === projectOwner && p.slug === projectSlug - v <- TableQuery[VersionTable] if p.id === v.projectId && v.versionString === name + v <- TableQuery[VersionTable] if v.projectId === request.scope.id && v.versionString === name } yield v.id service.runDBIO(versionIdQuery.result.head).flatMap { versionId => @@ -211,21 +208,27 @@ class Versions( version.foldLeftKC(false)(acc => Lambda[Option ~>: Const[Boolean]#λ](op => acc || op.isDefined)) val doEdit = if (!needEdit) Applicative[ConnectionIO].unit - else APIV2Queries.updateVersion(projectOwner, projectSlug, name, version).run.void + else APIV2Queries.updateVersion(request.scope.id, name, version).run.void handlePlatforms *> service .runDbCon( //We need two queries as we use the generic update function doEdit *> APIV2Queries .singleVersionQuery( - projectOwner, - projectSlug, + request.scope.id, name, request.globalPermissions.has(Permission.SeeHidden), request.user.map(_.id) ) .unique ) + .tap { version => + addWebhookJob( + Webhook.WebhookEventType.VersionEdited, + version, + ??? + ) + } .map(r => Ok(WithAlerts(r, warnings = warnings))) } case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e))) @@ -233,14 +236,12 @@ class Versions( } def showVersionChangelog(projectOwner: String, projectSlug: String, name: String): Action[AnyContent] = - CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + CachingApiAction(Permission.ViewPublicInfo, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => service .runDBIO( - TableQuery[ProjectTable] - .join(TableQuery[VersionTable]) - .on(_.id === _.projectId) - .filter(t => t._1.ownerName === projectOwner && t._1.slug === projectSlug && t._2.versionString === name) - .map(_._2.description) + TableQuery[VersionTable] + .filter(v => v.projectId === request.scope.id && v.versionString === name) + .map(_.description) .result .headOption ) @@ -251,14 +252,18 @@ class Versions( ApiAction(Permission.EditVersion, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[APIV2.VersionChangelog]) { implicit request => for { - project <- projects.withSlug(projectOwner, projectSlug).someOrFail(NotFound) - version <- project.versions(ModelView.now(Version)).find(_.versionString === name).value.someOrFail(NotFound) + 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) + _ <- addWebhookJob( + Webhook.WebhookEventType.VersionChangelogEdited, + APIV2.VersionChangelog(newDescription), + ??? + ) _ <- UserActionLogger.logApi( request, LoggedActionType.VersionDescriptionEdited, @@ -276,7 +281,7 @@ class Versions( fromDateString: String, toDateString: String ): Action[AnyContent] = - CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + CachingApiAction(Permission.IsProjectMember, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { request => import Ordering.Implicits._ def parseDate(dateStr: String) = @@ -292,7 +297,7 @@ class Versions( _ <- ZIO.unit.filterOrFail(_ => fromDate < toDate)(BadRequest(ApiError("From date is after to date"))) res <- service.runDbCon( APIV2Queries - .versionStats(projectOwner, projectSlug, version, fromDate, toDate) + .versionStats(request.scope.id, version, fromDate, toDate) .to[Vector] .map(APIV2VersionStatsQuery.asProtocol) ) @@ -305,8 +310,8 @@ class Versions( effectBlocking(java.nio.file.Files.readAllLines(file).asScala.mkString("\n")) } - private def processVersionUploadToErrors(projectOwner: String, projectSlug: String)( - implicit request: ApiRequest[MultipartFormData[Files.TemporaryFile]] + private def processVersionUploadToErrors( + implicit request: ApiRequest[ResolvedAPIScope.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"))) @@ -314,7 +319,7 @@ class Versions( for { user <- ZIO.fromOption(request.user).orElseFail(BadRequest(ApiError("No user found for session"))) - project <- projects.withSlug(projectOwner, projectSlug).get.orElseFail(NotFound) + project <- request.project file <- fileF pluginFile <- factory .collectErrorsForVersionUpload(PluginUpload(file.ref, file.filename), user, project) @@ -330,7 +335,7 @@ class Versions( parse.multipartFormData(config.ore.projects.uploadMaxSize.toBytes) ).asyncF { implicit request => for { - t <- processVersionUploadToErrors(projectOwner, projectSlug) + t <- processVersionUploadToErrors (user, _, pluginFile) = t } yield { val apiVersion = APIV2QueryVersion( @@ -385,7 +390,7 @@ class Versions( .mapError(e => BadRequest(ApiError(e))) for { - t <- processVersionUploadToErrors(projectOwner, projectSlug) + t <- processVersionUploadToErrors (user, project, pluginFile) = t data <- dataF t <- factory @@ -401,10 +406,8 @@ class Versions( implicit val lang: Lang = user.langOrDefault BadRequest(UserErrors(es.map(messagesApi(_)))) } - } yield { - val (_, version, platforms) = t - - val apiVersion = APIV2QueryVersion( + (_, version, platforms) = t + apiVersion = APIV2QueryVersion( version.createdAt, version.versionString, version.dependencyIds, @@ -423,74 +426,73 @@ class Versions( platforms.map(_.platformVersion).toList, version.postId ) - - Created(apiVersion.asProtocol) - } + _ <- addWebhookJob(Webhook.WebhookEventType.VersionCreated, apiVersion.asProtocol, ???) + } yield 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 => - projects - .withSlug(projectOwner, projectSlug) - .someOrFail(NotFound) - .mproduct { p => - ModelView - .now(Version) - .find(v => v.projectId === p.id.value && v.versionString === version) - .value - .someOrFail(NotFound) - } - .flatMap { - case (project, version) => - val log = UserActionLogger - .logApi( - request, - LoggedActionType.VersionDeleted, - version.id, - "", - "" - )(LoggedActionVersion(_, Some(project.id))) - .unit + request.version(version).flatMap { version => + val log = UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + "", + "" + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + + val addWebhookJobs = addWebhookJob( + Webhook.WebhookEventType.VersionDeleted, + APIV2.StandaloneVersionName(version.versionString), + ??? + ) - log *> projects.deleteVersion(version).as(NoContent) - } + addWebhookJobs *> 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 => - projects - .withSlug(projectOwner, projectSlug) - .someOrFail(NotFound) - .mproduct { p => - ModelView - .now(Version) - .find(v => v.projectId === p.id.value && v.versionString === version) - .value - .someOrFail(NotFound) - } - .flatMap { - case (project, 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(project.id))) - .unit - ) - } + request.version(version).flatMap { version => + //TODO: Add webhook action in here + 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) => + addWebhookJob( + Webhook.WebhookEventType.VersionDeleted, + APIV2.VersionVisibilityChange(version.versionString, APIV2.VisibilityChange(oldV, newV)), + ??? + ), + addWebhookJob( + Webhook.WebhookEventType.VersionDeleted, + APIV2.StandaloneVersionName(version.versionString), + ??? + ), + (newV, oldV) => + UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + newV, + oldV + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + ) + } } def editDiscourseSettings( @@ -500,22 +502,12 @@ class Versions( ): Action[Versions.DiscourseModifyPostSettings] = ApiAction(Permission.EditAdminSettings, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[Versions.DiscourseModifyPostSettings]) { implicit request => - projects - .withSlug(projectOwner, projectSlug) - .someOrFail(NotFound) - .flatMap { p => - ModelView - .now(Version) - .find(v => v.projectId === p.id.value && v.versionString === version) - .value - .someOrFail(NotFound) - } - .flatMap { version => - val update = service.update(version)(_.copy(postId = request.body.postId)) - val addJob = service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob) + 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) - } + update.as(NoContent) <* addJob.when(request.body.updatePost) + } } } object Versions { diff --git a/apiV2/app/controllers/apiv2/Webhooks.scala b/apiV2/app/controllers/apiv2/Webhooks.scala new file mode 100644 index 000000000..ce6bb875c --- /dev/null +++ b/apiV2/app/controllers/apiv2/Webhooks.scala @@ -0,0 +1,277 @@ +package controllers.apiv2 + +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.UUID + +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 controllers.sugar.ResolvedAPIScope +import db.impl.query.APIV2Queries +import models.protocols.APIV2 +import ore.db.Model +import ore.db.access.ModelView +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.schema.WebhookTable +import ore.models.Job +import ore.models.project.Webhook +import ore.models.project.Webhook.WebhookEventType +import ore.permission.Permission +import ore.util.CryptoUtils +import util.{PartialUtils, PatchDecoder} +import util.syntax._ + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpMethods, HttpRequest, Uri} +import cats.data.NonEmptyList +import cats.syntax.all._ +import io.circe._ +import io.circe.syntax._ +import io.circe.derivation.annotations.SnakeCaseJsonCodec +import squeal.category.macros.Derive +import squeal.category.syntax.all._ +import squeal.category._ +import zio.{IO, UIO, ZIO} + +class Webhooks( + val errorHandler: HttpErrorHandler, + lifecycle: ApplicationLifecycle +)( + implicit oreComponents: OreControllerComponents, + actorSystem: ActorSystem +) extends AbstractApiV2Controller(lifecycle) { + import Webhooks._ + + private val discordWebhookUrl = + """https://(?:(?:discordapp)|(?:discord))\.com/api/(?:(?:v6/)|(?:v8/))?webhooks/(\d+)/([^/]+)""".r + + private def validateCallbackUrl(callbackUrl: String, discordFormatted: Boolean) = + ZIO(Uri.parseAbsolute(callbackUrl)) + .orElseFail(BadRequest(ApiError("Invalid callback URL"))) + .filterOrFail(_.scheme == "https")(BadRequest(ApiError("Only HTTPS urls allowed"))) + .filterOrFail { uri => + val isDiscordHost = uri.authority.host.address == "discord" || uri.authority.host.address == "discordapp" + if (isDiscordHost) discordFormatted && discordWebhookUrl.matches(callbackUrl) + else true + }( + BadRequest(ApiError("Invalid url for discord formatted webhook")) + ) + .map(_.toString) + + private def makeWebhookSecret(): String = { + val secretBytes = new Array[Byte](32) + new SecureRandom().nextBytes(secretBytes) + CryptoUtils.bytesToHex(secretBytes) + } + + private def apiWebhookFromWebhook(webhook: Webhook): APIV2.Webhook = + APIV2.Webhook( + webhook.publicId, + webhook.name, + webhook.callbackUrl, + webhook.discordFormatted, + webhook.events, + webhook.lastError, + webhook.secret + ) + + private def pingWebhook( + webhook: Model[Webhook] + )(implicit request: ApiRequest[ResolvedAPIScope.ProjectScope, _]): UIO[Model[Webhook]] = { + webhook.callbackUrl match { + case discordWebhookUrl(_, _) => service.update(webhook)(_.copy(lastError = None)) + case _ => + val webhookType = Webhook.WebhookEventType.Ping + + val body = + Json + .obj( + "webhook_meta_info" := APIV2.WebhookPostData( + request.scope.projectOwner, + request.scope.projectSlug, + webhookType + ) + ) + .noSpaces + val unixTime = System.currentTimeMillis() / 1000 + val signature = CryptoUtils.hmac_sha256( + webhook.secret, + ByteBuffer.allocate(8).putLong(unixTime).array ++ body.getBytes("UTF-8") + ) + + for { + eitherResponse <- ZIO + .fromFuture(_ => + Http().singleRequest( + HttpRequest( + method = HttpMethods.GET, + uri = webhook.callbackUrl, + headers = Seq( + RawHeader("Ore-Webhook-EventType", webhookType.value), + RawHeader("Ore-Webhook-Timestamp", unixTime.toString), + RawHeader("Ore-Webhook-HMACSignature", signature) + ), + entity = HttpEntity( + ContentTypes.`application/json`, + body + ) + ) + ) + ) + .either + newError = eitherResponse match { + case Right(response) if response.status.isSuccess() => + response.discardEntityBytes() + None + case Right(response) => + response.discardEntityBytes() + Some(s"Encountered response code: ${response.status.intValue}") + case Left(e) => Some(s"Failed to run ping request: ${e.getMessage}") + } + res <- service.update(webhook)(_.copy(lastError = newError)) + } yield webhook + } + } + + def createWebhook(projectOwner: String, projectSlug: String): Action[CreateWebhookRequest] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[CreateWebhookRequest]) { implicit request => + val data = request.body + + val publicId = UUID.randomUUID() + val discordFormatted = data.discordFormatted.getOrElse(false) + + validateCallbackUrl(data.callbackUrl, discordFormatted).flatMap { uri => + val secret = makeWebhookSecret() + + service + .insert( + Webhook( + request.scope.id, + publicId, + data.name, + uri, + discordFormatted, + data.events.toList, + secret, + None + ) + ) + .flatMap(pingWebhook) + .map { webhook => + Created(apiWebhookFromWebhook(webhook)).withHeaders( + LOCATION -> routes.Webhooks.getWebhook(projectOwner, projectSlug, publicId.toString).absoluteURL() + ) + } + } + } + + def getWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[AnyContent] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + for { + uuidWebhookId <- IO(UUID.fromString(webhookId)).orElseFail(BadRequest) + webhook <- ModelView.now(Webhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) + } yield Ok(apiWebhookFromWebhook(webhook)) + } + + def pingWebhookAction(projectOwner: String, projectSlug: String, webhookId: String): Action[AnyContent] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { implicit request => + for { + uuidWebhookId <- IO(UUID.fromString(webhookId)).orElseFail(BadRequest) + webhook <- ModelView.now(Webhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) + pingedWebhook <- pingWebhook(webhook) + } yield Ok(apiWebhookFromWebhook(pingedWebhook)) + } + + def refreshSecret(projectOwner: String, projectSlug: String, webhookId: String): Action[AnyContent] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + for { + uuidWebhookId <- IO(UUID.fromString(webhookId)).orElseFail(BadRequest) + newSecret = makeWebhookSecret() + webhook <- ModelView.now(Webhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) + updatedWebhook <- service.update(webhook)(_.copy(secret = newSecret)) + _ <- service.deleteWhere(Job) { j => + j.jobType === (Job.PostWebhookResponse: Job.JobType) && j.jobProperties + .>>[Long]("foo") === updatedWebhook.id.value + } + } yield Ok(apiWebhookFromWebhook(updatedWebhook)) + } + + def editWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[Json] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF(parseCirce.json) { + implicit request => + for { + uuidWebhookId <- IO(UUID.fromString(webhookId)).orElseFail(BadRequest) + webhookEdits <- IO + .fromEither( + EditableWebhookF.patchDecoder + .traverseKC(PartialUtils.decodeAll(request.body.hcursor)) + .toEither: Either[NonEmptyList[Error], EditableWebhook] + ) + .mapError(e => BadRequest(ApiErrors(e.map(_.show)))) + validatedEdits <- ZIO + .foreach(webhookEdits.callbackUrl) { callbackUrl => + val discordFormattedF = webhookEdits.discordFormatted + .fold( + service.runDBIO( + TableQuery[WebhookTable] + .filter(_.publicId === uuidWebhookId) + .map(_.discordFormatted) + .result + .head + ) + )(b => UIO.succeed(b)) + + discordFormattedF.flatMap(validateCallbackUrl(callbackUrl, _)) + } + .map(callbackUrl => webhookEdits.copy[Option](callbackUrl = callbackUrl)) + _ <- service.runDbCon(APIV2Queries.updateWebhook(uuidWebhookId, validatedEdits).run) + updatedWebhook <- ModelView.now(Webhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) + pingedWebhook <- pingWebhook(updatedWebhook) + } yield Ok(apiWebhookFromWebhook(pingedWebhook)) + } + + def deleteWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[AnyContent] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF { + for { + uuidWebhookId <- IO(UUID.fromString(webhookId)).orElseFail(BadRequest) + _ <- service.deleteWhere(Webhook)(_.publicId === uuidWebhookId) + } yield NoContent + } +} +object Webhooks { + + import APIV2.webhookEventTypeCodec + + @SnakeCaseJsonCodec case class CreateWebhookRequest( + name: String, + callbackUrl: String, + discordFormatted: Option[Boolean], + events: Seq[WebhookEventType] + ) + + type EditableWebhook = EditableWebhookF[Option] + case class EditableWebhookF[F[_]]( + name: F[String], + callbackUrl: F[String], + discordFormatted: F[Boolean], + events: F[List[WebhookEventType]] + ) + object EditableWebhookF { + implicit val F + : ApplicativeKC[EditableWebhookF] with TraverseKC[EditableWebhookF] with DistributiveKC[EditableWebhookF] = + Derive.allKC[EditableWebhookF] + + val patchDecoder: EditableWebhookF[PatchDecoder] = + PatchDecoder.fromName(Derive.namesWithProductImplicitsC[EditableWebhookF, Decoder])( + io.circe.derivation.renaming.snakeCase + ) + } +} diff --git a/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala b/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala index b9c681cdb..308ffd2b8 100644 --- a/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala +++ b/apiV2/app/controllers/apiv2/helpers/EditVisibility.scala @@ -28,6 +28,8 @@ import zio.{IO, UIO, ZIO} deletePerm: Permission, insertDiscourseUpdateJob: UIO[Unit], doHardDelete: Model[A] => UIO[Unit], + createVisibilityChangeWebhookActions: (Visibility, Visibility) => UIO[Unit], + createDeleteWebhookActions: UIO[Unit], createLog: (String, String) => UIO[Unit] )(implicit jsonWrite: Writeable[Json], hide: Hideable[UIO, A]): ZIO[Any, Result, Result] = { import play.api.mvc.Results._ @@ -50,8 +52,13 @@ import zio.{IO, UIO, ZIO} 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) + if (toChange.hVisibility == Visibility.New) createDeleteWebhookActions *> doHardDelete(toChange) + else + createVisibilityChangeWebhookActions(visibility, toChange.hVisibility) *> toChange.setVisibility( + visibility, + comment, + changer + ) val log = createLog(visibility.nameKey, toChange.hVisibility.nameKey) diff --git a/apiV2/app/controllers/apiv2/helpers/Members.scala b/apiV2/app/controllers/apiv2/helpers/Members.scala index 99f712cf4..f71774c77 100644 --- a/apiV2/app/controllers/apiv2/helpers/Members.scala +++ b/apiV2/app/controllers/apiv2/helpers/Members.scala @@ -20,6 +20,7 @@ import zio.{IO, UIO, ZIO} import zio.interop.catz._ import play.api.mvc.Results._ +import controllers.sugar.ResolvedAPIScope import db.impl.access.UserBase import ore.db.access.ModelView import ore.db.impl.common.Named @@ -49,18 +50,14 @@ object Members { limit: Option[Long], offset: Long )( - implicit r: ApiRequest[_], - service: ModelService[UIO], - writeJson: Writeable[Json] - ): ZIO[Any, Nothing, Result] = { + implicit r: ApiRequest[_ <: ResolvedAPIScope, _], + service: ModelService[UIO] + ): ZIO[Any, Nothing, Vector[APIV2.Member]] = { 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) + if (r.scopePermission.has(Permission.ManageSubjectMembers)) xs + else xs.filter(_.role.isAccepted) } } @@ -76,14 +73,13 @@ object Members { notificationType: NotificationType, notificationLocalization: String )( - implicit r: ApiRequest[List[MemberUpdate]], + implicit r: ApiRequest[_ <: ResolvedAPIScope, 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] = + ): ZIO[Any, Result, Vector[APIV2.Member]] = for { subject <- getSubject resolvedUsers <- ZIO.foreach(r.body) { m => diff --git a/apiV2/app/controllers/apiv2/helpers/apiScope.scala b/apiV2/app/controllers/apiv2/helpers/apiScope.scala index 321bb7f9d..40e646302 100644 --- a/apiV2/app/controllers/apiv2/helpers/apiScope.scala +++ b/apiV2/app/controllers/apiv2/helpers/apiScope.scala @@ -2,16 +2,20 @@ package controllers.apiv2.helpers import scala.collection.immutable +import controllers.sugar.ResolvedAPIScope import models.protocols.APIV2 +import ore.permission.scope.Scope import enumeratum.{Enum, EnumEntry} import io.circe._ -sealed abstract class APIScope(val tpe: APIScopeType) +sealed abstract class APIScope[RealScope <: ResolvedAPIScope](val tpe: APIScopeType) object APIScope { - case object GlobalScope extends APIScope(APIScopeType.Global) - case class ProjectScope(projectOwner: String, projectSlug: String) extends APIScope(APIScopeType.Project) - case class OrganizationScope(organizationName: String) extends APIScope(APIScopeType.Organization) + case object GlobalScope extends APIScope[ResolvedAPIScope.GlobalScope.type](APIScopeType.Global) + case class ProjectScope(projectOwner: String, projectSlug: String) + extends APIScope[ResolvedAPIScope.ProjectScope](APIScopeType.Project) + case class OrganizationScope(organizationName: String) + extends APIScope[ResolvedAPIScope.OrganizationScope](APIScopeType.Organization) } sealed abstract class APIScopeType extends EnumEntry with EnumEntry.Snakecase diff --git a/apiV2/app/db/impl/query/APIV2Queries.scala b/apiV2/app/db/impl/query/APIV2Queries.scala index f73ef256f..d68b1415b 100644 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ b/apiV2/app/db/impl/query/APIV2Queries.scala @@ -1,13 +1,15 @@ package db.impl.query import java.time.LocalDate +import java.util.UUID import play.api.mvc.RequestHeader import controllers.apiv2.Users.UserSortingStrategy -import controllers.apiv2.{Pages, Projects, Users, Versions} +import controllers.apiv2.{Pages, Projects, Users, Versions, Webhooks} import controllers.sugar.Requests.ApiAuthInfo import models.protocols.APIV2 +import models.protocols.APIV2.Organization import models.querymodels._ import ore.{OreConfig, OrePlatform} import ore.data.project.Category @@ -345,7 +347,7 @@ object APIV2Queries extends DoobieOreProtocol { sql"""UPDATE """ ++ Fragment.const(table) ++ updates } - def updateProject(projectOwner: String, projectSlug: String, edits: Projects.EditableProject): Update0 = { + def updateProject(projectId: DbRef[Project], edits: Projects.EditableProject): Update0 = { val projectColumns = Projects.EditableProjectF[Column]( Column.arg("name"), Projects.EditableProjectNamespaceF[Column](Column.arg("owner_name")), @@ -372,34 +374,33 @@ object APIV2Queries extends DoobieOreProtocol { (fr", owner_id = u.id", fr"FROM users u", fr"AND u.name = $owner") } - (updateTable("projects", projectColumns, edits) ++ newOwnerSet ++ newOwnerFrom ++ fr" WHERE owner_name = $projectOwner AND lower(slug) = lower($projectSlug) " ++ newOwnerFilter).update + (updateTable("projects p", projectColumns, edits) ++ newOwnerSet ++ newOwnerFrom ++ fr" WHERE p.id = $projectId " ++ newOwnerFilter).update } - def projectMembers(projectOwner: String, projectSlug: String, limit: Long, offset: Long): Query0[APIV2.Member] = + def projectMembers(projectId: DbRef[Project], limit: Long, offset: Long): Query0[APIV2.Member] = sql"""|SELECT u.name, r.name, upr.is_accepted | 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.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) + | WHERE p.id = $projectId | ORDER BY r.permission & ~B'1'::BIT(64) DESC LIMIT $limit OFFSET $offset""".stripMargin .query[APIV2QueryMember] .map(_.asProtocol) - def orgaMembers(organization: String, limit: Long, offset: Long): Query0[APIV2.Member] = + def orgaMembers(organizationId: DbRef[Organization], limit: Long, offset: Long): Query0[APIV2.Member] = sql"""|SELECT u.name, r.name, uor.is_accepted | FROM organizations o | JOIN user_organization_roles uor ON o.id = uor.organization_id | JOIN users u ON uor.user_id = u.id | JOIN roles r ON uor.role_type = r.name - | WHERE o.name = $organization + | WHERE o.id = $organizationId | ORDER BY r.permission & ~B'1'::BIT(64) DESC LIMIT $limit OFFSET $offset""".stripMargin .query[APIV2QueryMember] .map(_.asProtocol) def versionSelectFrag( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], versionName: Option[String], platforms: List[(String, Option[String])], stability: List[Version.Stability], @@ -450,7 +451,7 @@ object APIV2Queries extends DoobieOreProtocol { } val filters = Fragments.whereAndOpt( - Some(fr"p.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug)"), + Some(fr"p.id = $projectId"), versionName.map(v => fr"pv.version_string = $v"), if (coarsePlatforms.isEmpty) None else @@ -471,8 +472,7 @@ object APIV2Queries extends DoobieOreProtocol { } def versionQuery( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], versionName: Option[String], platforms: List[(String, Option[String])], stability: List[Version.Stability], @@ -483,8 +483,7 @@ object APIV2Queries extends DoobieOreProtocol { offset: Long )(implicit config: OreConfig): Query0[APIV2.Version] = (versionSelectFrag( - projectOwner, - projectSlug, + projectId, versionName, platforms, stability, @@ -496,17 +495,15 @@ object APIV2Queries extends DoobieOreProtocol { .map(_.asProtocol) def singleVersionQuery( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], versionName: String, canSeeHidden: Boolean, currentUserId: Option[DbRef[User]] )(implicit config: OreConfig): doobie.Query0[APIV2.Version] = - versionQuery(projectOwner, projectSlug, Some(versionName), Nil, Nil, Nil, canSeeHidden, currentUserId, 1, 0) + versionQuery(projectId, Some(versionName), Nil, Nil, Nil, canSeeHidden, currentUserId, 1, 0) def versionCountQuery( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], platforms: List[(String, Option[String])], stability: List[Version.Stability], releaseType: List[Version.ReleaseType], @@ -514,12 +511,11 @@ object APIV2Queries extends DoobieOreProtocol { currentUserId: Option[DbRef[User]] )(implicit config: OreConfig): Query0[Long] = (sql"SELECT COUNT(*) FROM " ++ Fragments.parentheses( - versionSelectFrag(projectOwner, projectSlug, None, platforms, stability, releaseType, canSeeHidden, currentUserId) + versionSelectFrag(projectId, None, platforms, stability, releaseType, canSeeHidden, currentUserId) ) ++ fr"sq").query[Long] def updateVersion( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], versionName: String, edits: Versions.DbEditableVersion ): Update0 = { @@ -528,7 +524,7 @@ object APIV2Queries extends DoobieOreProtocol { Column.opt("release_type") ) - (updateTable("project_versions", versionColumns, edits) ++ fr" FROM projects p WHERE project_id = p.id AND p.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) AND version_string = $versionName").update + (updateTable("project_versions", versionColumns, edits) ++ fr" WHERE project_id = $projectId AND version_string = $versionName").update } def userSearchFrag( @@ -756,8 +752,7 @@ object APIV2Queries extends DoobieOreProtocol { ): Query0[Long] = actionCountQuery(Fragment.const("project_watchers"), user, canSeeHidden, currentUserId) def projectStats( - projectOwner: String, - projectSlug: String, + projectId: DbRef[Project], startDate: LocalDate, endDate: LocalDate ): Query0[APIV2ProjectStatsQuery] = @@ -766,25 +761,23 @@ object APIV2Queries extends DoobieOreProtocol { | (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.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) + | 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( - projectOwner: String, - projectSlug: String, + 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, + | FROM 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.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) + | WHERE pv.project_id = $projectId | AND pv.version_string = $versionString - | AND (pvd IS NULL OR (pvd.project_id = p.id AND pvd.version_id = pv.id));""".stripMargin + | AND (pvd IS NULL OR (pvd.project_id = $projectId AND pvd.version_id = pv.id));""".stripMargin .query[APIV2VersionStatsQuery] def canUploadToOrg(uploader: DbRef[User], orgName: String): Query0[(DbRef[User], Boolean)] = @@ -798,51 +791,42 @@ object APIV2Queries extends DoobieOreProtocol { | 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)] - def getPage( - projectOwner: String, - projectSlug: String, - page: String - ): Query0[(DbRef[Project], DbRef[Page], String, Option[String])] = - sql"""|WITH RECURSIVE pages_rec(n, name, slug, contents, id, project_id) AS ( - | SELECT 2, pp.name, pp.slug, pp.contents, pp.id, pp.project_id + 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 - | JOIN projects p ON pp.project_id = p.id - | WHERE p.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) + | 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, pp.project_id + | SELECT pr.n + 1, pp.name, pp.slug, pp.contents, pp.id | FROM pages_rec pr, | project_pages pp - | WHERE pp.project_id = pr.project_id + | WHERE pp.project_id = $projectId | AND pp.parent_id = pr.id | AND lower(split_part($page, '/', pr.n)) = lower(pp.slug) |) - |SELECT pp.project_id, pp.id, pp.name, pp.contents + |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[Project], DbRef[Page], String, Option[String])] + .query[(DbRef[Page], String, Option[String])] - def pageList( - projectOwner: String, - projectSlug: String - ): Query0[(DbRef[Project], DbRef[Page], List[String], List[String], Boolean)] = - sql"""|WITH RECURSIVE pages_rec(name, slug, id, project_id, navigational) AS ( - | SELECT ARRAY[pp.name]::TEXT[], ARRAY[pp.slug]::TEXT[], pp.id, pp.project_id, pp.contents IS NULL + 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 - | JOIN projects p ON pp.project_id = p.id - | WHERE p.owner_name = $projectOwner AND lower(p.slug) = lower($projectSlug) + | 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.project_id, pp.contents IS NULL + | 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 = pr.project_id + | WHERE pp.project_id = $projectId | AND pp.parent_id = pr.id |) - |SELECT pp.project_id, pp.id, pp.name, pp.slug, navigational + |SELECT pp.id, pp.name, pp.slug, navigational | FROM pages_rec pp ORDER BY pp.name;""".stripMargin - .query[(DbRef[Project], DbRef[Page], List[String], List[String], Boolean)] + .query[(DbRef[Page], List[String], List[String], Boolean)] def patchPage( patch: Pages.PatchPageF[Option], @@ -859,4 +843,18 @@ object APIV2Queries extends DoobieOreProtocol { (sql"UPDATE project_pages " ++ sets ++ fr"WHERE id = $id").update } + def updateWebhook(publicWebhookId: UUID, edits: Webhooks.EditableWebhook): Update0 = { + val webhookColumns = Webhooks.EditableWebhookF[Column]( + Column.arg("name"), + Column.arg("callback_url"), + Column.arg("discord_formatted"), + Column.arg("webhook_events") + ) + + import cats.instances.option._ + import cats.instances.tuple._ + + (updateTable("project_webhooks", webhookColumns, edits) ++ fr" WHERE public_id = $publicWebhookId").update + } + } diff --git a/apiV2/app/models/protocols/APIV2.scala b/apiV2/app/models/protocols/APIV2.scala index bc7580fa9..46684ec6a 100644 --- a/apiV2/app/models/protocols/APIV2.scala +++ b/apiV2/app/models/protocols/APIV2.scala @@ -1,6 +1,7 @@ package models.protocols import java.time.OffsetDateTime +import java.util.UUID import ore.data.project.Category import ore.models.project.Version.{ReleaseType, Stability} @@ -209,4 +210,65 @@ object APIV2 { slug: Seq[String], navigational: Boolean ) + + @SnakeCaseJsonCodec case class PageWithSlug( + name: String, + slug: Seq[String], + navigational: Boolean, + content: Option[String] + ) + + @SnakeCaseJsonCodec case class PageUpdateWithSlug( + name: String, + oldSlug: Seq[String], + newSlug: Seq[String], + navigational: Boolean, + content: Option[String] + ) + + @SnakeCaseJsonCodec case class PageSlug( + slug: Seq[String] + ) + + @SnakeCaseJsonCodec case class VersionVisibilityChange( + version: String, + change: VisibilityChange + ) + + @SnakeCaseJsonCodec case class VisibilityChange( + oldVisibility: Visibility, + newVisibility: Visibility + ) + + @SnakeCaseJsonCodec case class StandaloneUser( + user: String + ) + + @SnakeCaseJsonCodec case class StandaloneVersionName( + name: String + ) + + @SnakeCaseJsonCodec case class Webhook( + id: UUID, + name: String, + callbackUrl: String, + discordFormatted: Boolean, + events: Seq[ore.models.project.Webhook.WebhookEventType], + lastError: Option[String], + secret: String + ) + + @SnakeCaseJsonCodec case class WebhookPostData( + projectOwner: String, + projectSlug: String, + eventType: ore.models.project.Webhook.WebhookEventType + ) + + @SnakeCaseJsonCodec case class MembersUpdate( + oldMembers: List[Member], + newMembers: List[Member] + ) + + implicit val webhookEventTypeCodec: Codec[ore.models.project.Webhook.WebhookEventType] = + valueEnumCodec(ore.models.project.Webhook.WebhookEventType)(_.value) } diff --git a/apiV2/conf/apiv2.routes b/apiV2/conf/apiv2.routes index bc6e8f0c2..fd0eb6325 100644 --- a/apiV2/conf/apiv2.routes +++ b/apiV2/conf/apiv2.routes @@ -1003,7 +1003,7 @@ GET /projects/:projectOwner/:projectSlug/_pages/*page @controllers.api # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf ++nocsrf PUT /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.putPage(projectOwner, projectSlug, page) ### @@ -1033,7 +1033,7 @@ PUT /projects/:projectOwner/:projectSlug/_pages/*page @c # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf ++nocsrf PATCH /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.patchPage(projectOwner, projectSlug, page) ### @@ -1053,15 +1053,404 @@ PATCH /projects/:projectOwner/:projectSlug/_pages/*page @c # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf ++nocsrf DELETE /projects/:projectOwner/:projectSlug/_pages/*page @controllers.apiv2.Pages.deletePage(projectOwner, projectSlug, page) ### NoDocs ### -#+nocsrf ++nocsrf GET /projects/:projectOwner/:projectSlug/_projectData @controllers.apiv2.Projects.projectData(projectOwner, projectSlug) +### +# summary: Creates a webhook for this project +# description: >- +# Creates a webhook for this project. Requires the `edit_webhooks` +# permission in the project or owning organization. +# tags: +# - Webhooks +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/controllers.apiv2.Webhooks.CreateWebhookRequest' +# responses: +# 201: +# description: Webhook created +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Webhook' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +# callbacks: +# ping: +# '{$request.body#/callback_url}': +# post: +# security: [] +# description: >- +# All webhooks (with the exception of Discord formatted ones) need +# to reply with OK to this message. Ore uses this to check that the +# webhook works. +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# responses: +# 200: +# description: Ok +# version_created: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# responses: +# 200: +# description: Ok +# version_changelog_edited: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' +# responses: +# 200: +# description: Ok +# version_edited: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# responses: +# 200: +# description: Ok +# version_visibility_change: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.VersionVisibilityChange' +# responses: +# 200: +# description: Ok +# version_deleted: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVersionName' +# responses: +# 200: +# description: Ok +# page_created: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.PageWithSlug' +# responses: +# 200: +# description: Ok +# page_updated: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.PageUpdateWithSlug' +# responses: +# 200: +# description: Ok +# page_deleted: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' +# responses: +# 200: +# description: Ok +# member_changed: +# '{$request.body#/callback_url}': +# post: +# security: [] +# parameters: +# - $ref: '#/components/parameters/webhookEventTypeHeader' +# - $ref: '#/components/parameters/webhookTimestampHeader' +# - $ref: '#/components/parameters/webhookSignatureHeader' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# webhook: +# $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# data: +# $ref: '#/components/schemas/models.protocols.APIV2.MembersUpdate' +# responses: +# 200: +# description: Ok +### ++nocsrf +POST /projects/:projectOwner/:projectSlug/webhooks @controllers.apiv2.Webhooks.createWebhook(projectOwner, projectSlug) + +### +# summary: Show an existing webhook for this project +# description: >- +# Show an existing webhook for this project. Requires the `edit_webhooks` +# permission in the project or owning organization. +# tags: +# - Webhooks +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Webhook' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +GET /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Webhooks.getWebhook(projectOwner, projectSlug, webhookId) + +### +# summary: Ping an existing webhook. +# description: >- +# Use this to ensure that the webhook is working. If Ore thinks an webhook +# isn't working, it won't send requests to it. Requires the `edit_webhooks` +# permission in the project or owning organization. +# tags: +# - Webhooks +# responses: +# 200: +# description: Pinged webhook +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Webhook' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +POST /projects/:projectOwner/:projectSlug/webhooks/:webhookId/ping @controllers.apiv2.Webhooks.pingWebhookAction(projectOwner, projectSlug, webhookId) + +### +# summary: Refresh the secret for an existing webhook. +# description: >- +# Refresh the secret for an existing webhook. Note that doing this will +# cancel the sending of all webhooks that haven't already been sent. +# Requires the `edit_webhooks` permission in the project or owning organization. +# tags: +# - Webhooks +# responses: +# 200: +# description: Secret refreshed +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Webhook' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +POST /projects/:projectOwner/:projectSlug/webhooks/:webhookId/refreshSecret @controllers.apiv2.Webhooks.refreshSecret(projectOwner, projectSlug, webhookId) + +### +# summary: Edit an existing webhook for this project +# description: >- +# Edit an existing webhook for this project. Requires the `edit_webhooks` +# permission in the project or owning organization. +# tags: +# - Webhooks +# requestBody: +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# name: +# type: string +# nullable: true +# callback_url: +# type: string +# nullable: true +# discord_formatted: +# type: boolean +# nullable: true +# events: +# type: array +# nullable: true +# items: +# $ref: '#/components/schemas/WebhookEventType' +# +# responses: +# 200: +# description: Ok +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/models.protocols.APIV2.Webhook' +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +PATCH /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Webhooks.editWebhook(projectOwner, projectSlug, webhookId) + +### +# summary: Delete an existing webhook for this project +# description: >- +# Delete an existing webhook for this project. Requires the `edit_webhooks` +# permission in the project or owning organization. +# tags: +# - Webhooks +# responses: +# 204: +# description: Deleted +# 401: +# $ref: '#/components/responses/UnauthorizedError' +# 403: +# $ref: '#/components/responses/ForbiddenError' +### ++nocsrf +DELETE /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Webhooks.deleteWebhook(projectOwner, projectSlug, webhookId) + ### NoDocs ### GET /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) +### NoDocs ### +POST /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) +### NoDocs ### +PATCH /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) +### NoDocs ### +PUT /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) +### NoDocs ### +DELETE /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) ### # summary: Searches the users on Ore diff --git a/auth/src/main/scala/ore/auth/AkkaSSOApi.scala b/auth/src/main/scala/ore/auth/AkkaSSOApi.scala index 3f6a7fff4..1b0c216d8 100644 --- a/auth/src/main/scala/ore/auth/AkkaSSOApi.scala +++ b/auth/src/main/scala/ore/auth/AkkaSSOApi.scala @@ -9,6 +9,7 @@ import scala.concurrent.duration.FiniteDuration import scala.util.Try import ore.external.Cacher +import ore.util.CryptoUtils import akka.actor.ActorSystem import akka.http.scaladsl.Http diff --git a/build.sbt b/build.sbt index 744af8fe4..8641812a6 100644 --- a/build.sbt +++ b/build.sbt @@ -43,7 +43,7 @@ lazy val discourse = project ) lazy val auth = project - .dependsOn(externalCommon) + .dependsOn(externalCommon, models) //Only reason models is here is because CryptoUtils .settings( Settings.commonSettings, name := "ore-auth" @@ -84,13 +84,14 @@ lazy val jobs = project Deps.scalaLogging, Deps.logback, Deps.sentry, - Deps.pureConfig + Deps.pureConfig, + Deps.ackcordRequests ) ) lazy val orePlayCommon: Project = project .enablePlugins(PlayScala) - .dependsOn(auth, models) + .dependsOn(auth) .settings( Settings.commonSettings, Settings.playCommonSettings, @@ -102,7 +103,8 @@ lazy val orePlayCommon: Project = project Deps.slickPlay, Deps.zio, Deps.zioCats, - Deps.pureConfig + Deps.pureConfig, + Deps.ackcordRequests ), aggregateReverseRoutes := Seq(ore) ) @@ -182,7 +184,8 @@ lazy val ore = project "controllers.apiv2.Permissions", "controllers.apiv2.Projects", "controllers.apiv2.Users", - "controllers.apiv2.Versions" + "controllers.apiv2.Versions", + "controllers.apiv2.Webhooks" ), swaggerNamingStrategy := "snake_case", swaggerAPIVersion := "2.0", diff --git a/jobs/src/main/scala/ore/JobsProcessor.scala b/jobs/src/main/scala/ore/JobsProcessor.scala index 884438f7c..c5206230b 100644 --- a/jobs/src/main/scala/ore/JobsProcessor.scala +++ b/jobs/src/main/scala/ore/JobsProcessor.scala @@ -1,5 +1,6 @@ package ore +import java.nio.ByteBuffer import java.time.OffsetDateTime import scala.concurrent.TimeoutException @@ -7,15 +8,37 @@ import scala.concurrent.duration._ import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ -import ore.db.impl.schema.{ProjectTable, VersionTable} -import ore.db.{Model, ObjId} +import ore.db.impl.schema.{ProjectTable, VersionTable, WebhookTable} +import ore.db.{DbRef, Model, ObjId} import ore.discourse.DiscourseError -import ore.models.project.{Project, Version} +import ore.models.project.{Project, Version, Webhook} import ore.models.{Job, JobInfo} - +import ore.util.CryptoUtils + +import ackcord.data.{MessageFlags, OutgoingEmbed, SnowflakeType} +import ackcord.requests.{ + AllowedMention, + ExecuteWebhook, + ExecuteWebhookData, + HttpException, + Ratelimiter, + RequestDropped, + RequestError, + RequestRatelimited, + RequestResponse +} +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpMethods, HttpRequest} import akka.pattern.CircuitBreakerOpenException +import akka.util.Timeout import com.typesafe.scalalogging import cats.syntax.all._ +import enumeratum.values.{ValueEnum, ValueEnumEntry} +import io.circe._ +import io.circe.syntax._ +import shapeless.Typeable import slick.lifted.TableQuery import zio._ import zio.clock.Clock @@ -23,7 +46,7 @@ import zio.clock.Clock object JobsProcessor { private val Logger = scalalogging.Logger("JobsProcessor") - def fiber: URIO[Db with Clock with Discourse with Config, Unit] = + def fiber: URIO[Db with Clock with Discourse with Discord with Actors with Config, Unit] = tryGetJob.flatMap { case Some(job) => (logJob(job) *> decodeJob(job).mapError(Right.apply) >>= processJob >>= finishJob).catchAll(logErrors) *> fiber @@ -45,14 +68,15 @@ object JobsProcessor { private def updateJobInfo(job: Job)(update: JobInfo => JobInfo): Job = job.copy(info = update(job.info).copy(lastUpdated = Some(OffsetDateTime.now()))) - private def asFatalFailure(job: Job, error: String, errorDescriptor: String): Job = { - updateJobInfo(job)( - _.copy( - lastUpdated = Some(OffsetDateTime.now()), - lastError = Some(error), - lastErrorDescriptor = Some(errorDescriptor), - state = Job.JobState.FatalFailure - ) + private def asFatalFailure(job: Job, error: String, errorDescriptor: String): Job = + updateJobInfo(job)(asFatalFailureInfo(_, error, errorDescriptor)) + + private def asFatalFailureInfo(jobInfo: JobInfo, error: String, errorDescriptor: String): JobInfo = { + jobInfo.copy( + lastUpdated = Some(OffsetDateTime.now()), + lastError = Some(error), + lastErrorDescriptor = Some(errorDescriptor), + state = Job.JobState.FatalFailure ) } @@ -87,14 +111,12 @@ object JobsProcessor { ): ZIO[Db, Either[Unit, String], A] = data.flatMap { case Some(value) => ZIO.succeed(value) case None => - ZIO - .accessM[Db](convertJob(job, _)(_.get.update(_)(asFatalFailure(_, error, errorDescriptor)))) - .andThen(ZIO.fail(Right(error))) + updateJob(job, asFatalFailureInfo(_, error, errorDescriptor)) *> ZIO.fail(Right(error)) } private def processJob( job: Model[Job.TypedJob] - ): ZIO[Db with Discourse with Config, Either[Unit, String], Model[Job.TypedJob]] = + ): ZIO[Db with Discourse with Discord with Actors with Config, Either[Unit, String], Model[Job.TypedJob]] = setLastUpdated(job) *> ZIO.access[Db](_.get).flatMap { implicit service => job.obj match { case Job.UpdateDiscourseProjectTopic(_, projectId) => @@ -127,6 +149,41 @@ object JobsProcessor { case Job.PostDiscourseReply(_, topicId, poster, content) => postReply(job, topicId, poster, content) + + case Job.PostWebhookResponse( + _, + projectOwner, + projectSlug, + webhookId, + webhookSecret, + callbackUrl, + webhookType, + data + ) => + def optionToResult[A](s: String, opt: String => Option[A], history: List[CursorOp])( + implicit tpe: Typeable[A] + ): Either[DecodingFailure, A] = + opt(s).toRight(DecodingFailure(s"$s is not a valid ${tpe.describe}", history)) + + def valueEnumCodec[V, A <: ValueEnumEntry[V]: Typeable]( + enumObj: ValueEnum[V, A] + )(name: A => String): Codec[A] = + Codec.from( + (c: HCursor) => + c.as[String].flatMap(optionToResult(_, s => enumObj.values.find(a => name(a) == s), c.history)), + (a: A) => name(a).asJson + ) + + implicit val webhookEventTypeCodec: Codec[ore.models.project.Webhook.WebhookEventType] = + valueEnumCodec(ore.models.project.Webhook.WebhookEventType)(_.value) + + val webhookExtraInfo = Json.obj( + "project_owner" := projectOwner, + "project_slug" := projectSlug, + "event_type" := webhookType + ) + + executeWebhook(job, webhookId, webhookType, webhookSecret, callbackUrl, data, webhookExtraInfo) } } @@ -211,4 +268,178 @@ object JobsProcessor { private def postReply(job: Model[Job.TypedJob], topicId: Int, poster: String, content: String) = handleDiscourseErrors(job)(_.get.postDiscussionReply(topicId, poster, content).map(_.void)) + private val discordWebhookUrl = + """https://(?:(?:discordapp)|(?:discord))\.com/api/(?:(?:v6/)|(?:v8/))?webhooks/(\d+)/([^/]+)""".r + + private def executeWebhook( + job: Model[Job.TypedJob], + webhookId: DbRef[Webhook], + webhookType: Webhook.WebhookEventType, + webhookSecret: String, + url: String, + data: Json, + webhookExtraInfo: Json + ) = url match { + case discordWebhookUrl(webhookId, webhookToken) => + executeDiscordWebhook(job, webhookId, webhookToken, data) + case _ => + def fatalError(error: String, errorDescriptor: String) = + updateJob( + job, + asFatalFailureInfo(_, error, errorDescriptor) + ).andThen(ZIO.succeed(error.asRight[Unit])) + + ZIO + .accessM[Actors] { hasActors => + implicit val system: ActorSystem = hasActors.get + + val body = Json.obj("webhook_meta_info" -> webhookExtraInfo, "data" -> data).noSpaces + val unixTime = System.currentTimeMillis() / 1000 + val signature = CryptoUtils.hmac_sha256( + webhookSecret, + ByteBuffer.allocate(8).putLong(unixTime).array ++ body.getBytes("UTF-8") + ) + + ZIO + .fromFuture { _ => + Http().singleRequest( + HttpRequest( + method = HttpMethods.POST, + uri = url, + headers = Seq( + RawHeader("Ore-Webhook-EventType", webhookType.value), + RawHeader("Ore-Webhook-Timestamp", unixTime.toString), + RawHeader("Ore-Webhook-HMACSignature", signature) + ), + entity = HttpEntity(ContentTypes.`application/json`, body) + ) + ) + } + .tap(response => UIO(response.discardEntityBytes())) + } + .flatMapError { e => + fatalError(s"Failed to send webhook to $url with error ${e.getMessage}", "webhook_request_error") + } + .flatMap { response => + if (response.status.isSuccess) UIO.succeed(job) + else + ZIO + .accessM[Db]( + _.get.runDBIO( + TableQuery[WebhookTable] + .filter(_.id === webhookId) + .map(_.lastError.?) + .update(Some(s"Encountered response code: ${response.status.intValue}")) + ) + ) + .as(job) + } + } + + private def executeDiscordWebhook( + job: Model[Job.TypedJob], + webhookIdString: String, + webhookToken: String, + json: Json + ) = { + implicit val ExecuteWebhookDataDecoder: Decoder[ExecuteWebhookData] = (c: HCursor) => { + import ackcord.data.DiscordProtocol._ + for { + content <- c.get[String]("content") + username <- c.get[Option[String]]("username") + avatarUrl <- c.get[Option[String]]("avatar_url") + tts <- c.get[Option[Boolean]]("tts") + embeds <- c.get[Seq[OutgoingEmbed]]("embeds") + allowedMentions <- c.get[Option[AllowedMention]]("allowed_mentions") + flags <- c.get[MessageFlags]("flags") + } yield ExecuteWebhookData(content, username, avatarUrl, tts, Nil, embeds, allowedMentions, flags) + + } + + def fatalError(error: String, errorDescriptor: String) = + updateJob( + job, + asFatalFailureInfo(_, error, errorDescriptor) + ).andThen(ZIO.succeed(error.asRight[Unit])) + + def retryIn(error: Option[String], errorDescriptor: Option[String])(duration: FiniteDuration) = + updateJob( + job, + _.copy( + retryAt = Some(OffsetDateTime.now().plusNanos((duration + 5.seconds).toNanos)), + lastError = error.orElse(job.info.lastError), + lastErrorDescriptor = errorDescriptor.orElse(job.info.lastErrorDescriptor), + state = Job.JobState.NotStarted + ) + ).andThen(ZIO.fail(error.toRight(()))) + + for { + //TODO: Fix this cast in AckCord + webhookId <- ZIO( + SnowflakeType[ackcord.data.Webhook](webhookIdString) + .asInstanceOf[ackcord.data.SnowflakeType[ackcord.data.Webhook]] + ).flatMapError(_ => fatalError(s"$webhookIdString is not a valid snowflake", "discord_invalid_snowflake")) + executeWebhookData <- ZIO + .fromEither(json.as[ExecuteWebhookData]) + .flatMapError(decodeFailure => fatalError(decodeFailure.show, "discord_decode_execute_webhook_data_failed")) + request = ExecuteWebhook(webhookId, webhookToken, waitQuery = false, executeWebhookData) + answer <- ZIO.accessM[Discord](d => ZIO.fromFuture(_ => d.get.singleFuture(request))).flatMapError { e => + fatalError( + s"""|Failed to send discord webhook + |Error: ${e.getMessage} + |WebhookId: $webhookIdString + |WebhookToken: $webhookToken""".stripMargin, + "discord_run_request_failed" + ) + } + _ <- answer match { + case RequestResponse(_, _, _, _) => UIO.unit + case RequestRatelimited(_, ratelimitInfo, _, _) => retryIn(None, None)(ratelimitInfo.tilReset) + case RequestError(e: HttpException, _, _) => + fatalError( + s"""|Encountered error when executing discord webhook + |StatusCode: ${e.statusCode.intValue} + |StatusReason: ${e.statusCode.reason} + |ExtraInfo: ${e.extraInfo.getOrElse("")} + |WebhookId: $webhookIdString + |WebhookToken: $webhookToken""".stripMargin, + "discord_run_request_error" + ).flip + + case RequestError(e, _, _) => + fatalError( + s"""|Failed to send discord webhook + |Error: ${e.getMessage} + |WebhookId: $webhookIdString + |WebhookToken: $webhookToken""".stripMargin, + "discord_run_request_failed" + ).flip + + case RequestDropped(route, _) => + ZIO + .accessM[Discord] { hasRequests => + import akka.actor.typed.scaladsl.AskPattern._ + val requests = hasRequests.get + import requests.system + + implicit val askTimeout: Timeout = 1.second + + ZIO + .fromFuture { _ => + requests.settings.ratelimitActor.ask[Either[Duration, Int]](replyTo => + Ratelimiter.QueryRatelimits(route, replyTo) + ) + } + .map(_.swap.getOrElse(Duration.Zero)) + .orElseSucceed(60.seconds) + .map { + case d: FiniteDuration => d + case _ => 60.seconds + } + } + .flatMap(retryIn(None, None)) + } + } yield job + } + } diff --git a/jobs/src/main/scala/ore/OreJobProcessorMain.scala b/jobs/src/main/scala/ore/OreJobProcessorMain.scala index 191e1b788..96503926d 100644 --- a/jobs/src/main/scala/ore/OreJobProcessorMain.scala +++ b/jobs/src/main/scala/ore/OreJobProcessorMain.scala @@ -14,7 +14,9 @@ import ore.discourse.AkkaDiscourseApi.AkkaDiscourseSettings import ore.discourse.{AkkaDiscourseApi, DiscourseApi, OreDiscourseApi, OreDiscourseApiEnabled} import ore.models.Job +import ackcord.requests.{Ratelimiter, RequestSettings, Requests} import akka.actor.{ActorSystem, Terminated} +import akka.http.scaladsl.model.headers.`User-Agent` import cats.effect.{Blocker, ConcurrentEffect, Resource} import cats.tagless.syntax.all._ import cats.~> @@ -48,7 +50,8 @@ object OreJobProcessorMain extends zio.ManagedApp { val uioModelServiceL = taskModelServiceL.map(h => Has(h.get.mapK(Lambda[Task ~> UIO](task => task.orDie)))) val discourseApiL = (configLayer ++ actorSystemLayer) >>> discourseApiLayer val oreDiscourseL = (discourseApiL ++ configLayer ++ taskModelServiceL) >>> oreDiscourseLayer - val oreEnvL = uioModelServiceL ++ configLayer ++ oreDiscourseL + val discordL = (actorSystemLayer ++ configLayer) >>> discordLayer + val oreEnvL = uioModelServiceL ++ configLayer ++ oreDiscourseL ++ discordL ++ actorSystemLayer val all: ZManaged[OreEnv with Has[SlickDb], Nothing, ExitCode] = for { maxConnections <- ZManaged.access[Has[SlickDb]](_.get.source.maxConnections.getOrElse(32)) @@ -145,7 +148,7 @@ object OreJobProcessorMain extends zio.ManagedApp { private val modelServiceLayer: ZLayer[Has[SlickDb] with Has[Transactor[Task]], Nothing, Has[ModelService[Task]]] = ZLayer.fromServices[SlickDb, Transactor[Task], ModelService[Task]](new OreModelService(_, _)) - private val actorSystemLayer: ZLayer[Any, Nothing, Has[ActorSystem]] = + private val actorSystemLayer: ZLayer[Any, Nothing, Actors] = ZManaged .make(UIO(ActorSystem("OreJobs"))) { system => val terminate: ZIO[Any, Unit, Terminated] = ZIO @@ -166,7 +169,7 @@ object OreJobProcessorMain extends zio.ManagedApp { } .toLayer - private val discourseApiLayer: ZLayer[Config with Has[ActorSystem], ExitCode, Has[DiscourseApi[Task]]] = + private val discourseApiLayer: ZLayer[Config with Actors, ExitCode, Has[DiscourseApi[Task]]] = ZLayer.fromServicesM[OreJobsConfig, ActorSystem, Any, ExitCode, DiscourseApi[Task]] { (config: OreJobsConfig, system: ActorSystem) => implicit val impSystem: ActorSystem = system @@ -214,4 +217,23 @@ object OreJobProcessorMain extends zio.ManagedApp { f.flatMapError(logErrorExitCode("Failed to create ore discourse client")) } } + + private val discordLayer: ZLayer[Actors with Config, ExitCode, Discord] = { + import akka.actor.typed.scaladsl.adapter._ + ZLayer.fromServicesM[ActorSystem, OreJobsConfig, Any, ExitCode, Requests] { (actors, config) => + ZIO(actors.spawn(Ratelimiter(), "DiscordRatelimiter")) + .map { ratelimiter => + new Requests( + RequestSettings( + credentials = None, + ratelimitActor = ratelimiter, + userAgent = `User-Agent`(config.webhooks.discordUserAgent) + ) + )( + actors.toTyped + ) + } + .flatMapError(logErrorExitCode("Failed to create discord requests ratelimiter")) + } + } } diff --git a/jobs/src/main/scala/ore/OreJobsConfig.scala b/jobs/src/main/scala/ore/OreJobsConfig.scala index f386fc4f4..5b8b622d3 100644 --- a/jobs/src/main/scala/ore/OreJobsConfig.scala +++ b/jobs/src/main/scala/ore/OreJobsConfig.scala @@ -8,7 +8,8 @@ import pureconfig.generic.auto._ case class OreJobsConfig( ore: OreJobsConfig.Ore, discourse: OreJobsConfig.Discourse, - jobs: OreJobsConfig.Jobs + jobs: OreJobsConfig.Jobs, + webhooks: OreJobsConfig.Webhooks ) object OreJobsConfig { @@ -48,4 +49,8 @@ object OreJobsConfig { statusError: FiniteDuration, notAvailable: FiniteDuration ) + + case class Webhooks( + discordUserAgent: String + ) } diff --git a/jobs/src/main/scala/ore/package.scala b/jobs/src/main/scala/ore/package.scala index 2fadc9d23..76d06ea88 100644 --- a/jobs/src/main/scala/ore/package.scala +++ b/jobs/src/main/scala/ore/package.scala @@ -1,12 +1,16 @@ import ore.db.ModelService import ore.discourse.OreDiscourseApi +import ackcord.requests.Requests +import akka.actor.ActorSystem import zio.{Has, Task, UIO} package object ore { type Config = Has[OreJobsConfig] type Db = Has[ModelService[UIO]] type Discourse = Has[OreDiscourseApi[Task]] + type Discord = Has[Requests] + type Actors = Has[ActorSystem] - type OreEnv = zio.ZEnv with Config with Db with Discourse + type OreEnv = zio.ZEnv with Config with Db with Discourse with Discord with Actors } diff --git a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala index d623f5eda..18c538749 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, Version, Visibility} +import ore.models.project.{ReviewState, TagColor, Version, Visibility, Webhook} import ore.models.user.{LoggedActionContext, LoggedActionType} import ore.permission.Permission import ore.permission.role.{Role, RoleCategory} @@ -93,6 +93,9 @@ trait OrePostgresDriver implicit val langTypeMapper: BaseColumnType[Locale] = MappedJdbcType.base[Locale, String](_.toLanguageTag, Locale.forLanguageTag) + implicit val webhookEventTypeMapper: BaseColumnType[Webhook.WebhookEventType] = + MappedJdbcType.base[Webhook.WebhookEventType, String](_.value, Webhook.WebhookEventType.withValue) + implicit val permissionTypeMapper: BaseColumnType[Permission] = new DriverJdbcType[Permission] { override def sqlType: Int = java.sql.Types.BIT @@ -137,6 +140,16 @@ trait OrePostgresDriver value => utils.SimpleArrayUtils.mkString[Prompt](_.value.toString)(value) ).to(_.toList) + implicit val webhookEventTypeListMapper: DriverJdbcType[List[Webhook.WebhookEventType]] = + new AdvancedArrayJdbcType[Webhook.WebhookEventType]( + "varchar", + str => + utils.SimpleArrayUtils + .fromString[Webhook.WebhookEventType](s => Webhook.WebhookEventType.withValue(s))(str) + .orNull, + value => utils.SimpleArrayUtils.mkString[Webhook.WebhookEventType](_.value)(value) + ).to(_.toList) + implicit val roleCategoryTypeMapper: JdbcType[RoleCategory] = pgEnumForValueEnum("ROLE_CATEGORY", RoleCategory) implicit val jobStateTypeMapper: JdbcType[Job.JobState] = pgEnumForValueEnum("JOB_STATE", Job.JobState) 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 d0f9dbb4d..817356a2d 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, Version, Visibility} +import ore.models.project.{ReviewState, TagColor, Version, Visibility, Webhook} import ore.models.user.{LoggedActionContext, LoggedActionType, User} import ore.permission.Permission import ore.permission.role.{Role, RoleCategory} @@ -183,8 +183,9 @@ trait DoobieOreProtocol { .asInstanceOf[Meta[LoggedActionType[Ctx]]] // scalafix:ok implicit def loggedActionContextMeta[Ctx]: Meta[LoggedActionContext[Ctx]] = enumeratumMeta(LoggedActionContext).asInstanceOf[Meta[LoggedActionContext[Ctx]]] // scalafix:ok - implicit val reviewStateMeta: Meta[ReviewState] = enumeratumMeta(ReviewState) - implicit val jobTypeMeta: Meta[Job.JobType] = enumeratumMeta(Job.JobType) + implicit val reviewStateMeta: Meta[ReviewState] = enumeratumMeta(ReviewState) + implicit val jobTypeMeta: Meta[Job.JobType] = enumeratumMeta(Job.JobType) + implicit val webhookEventTypeMeta: Meta[Webhook.WebhookEventType] = enumeratumMeta(Webhook.WebhookEventType) implicit val langMeta: Meta[Locale] = Meta[String].timap(Locale.forLanguageTag)(_.toLanguageTag) implicit val inetStringMeta: Meta[InetString] = @@ -225,6 +226,8 @@ trait DoobieOreProtocol { metaFromGetPut[List[Int]].timap(_.map(Prompt.withValue))(_.map(_.value)) implicit val roleTypeArrayMeta: Meta[List[Role]] = metaFromGetPut[List[String]].timap(_.map(Role.withValue))(_.map(_.value)) + implicit val webhookEventTypeListMeta: Meta[List[Webhook.WebhookEventType]] = + metaFromGetPut[List[String]].timap(_.map(Webhook.WebhookEventType.withValue))(_.map(_.value)) implicit val tagColorArrayMeta: Meta[List[TagColor]] = Meta[Array[Int]].timap(_.toList.map(TagColor.withValue))(_.map(_.value).toArray) diff --git a/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala new file mode 100644 index 000000000..9e33ad0cf --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala @@ -0,0 +1,25 @@ +package ore.db.impl.schema + +import java.util.UUID + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.project.{Project, Webhook} + +class WebhookTable(tag: Tag) extends ModelTable[Webhook](tag, "project_webhooks") { + + def projectId = column[DbRef[Project]]("project_id") + def publicId = column[UUID]("public_id") + def name = column[String]("name") + def callbackUrl = column[String]("callback_url") + def discordFormatted = column[Boolean]("discord_formatted") + def eventTypes = column[List[Webhook.WebhookEventType]]("event_types") + def secret = column[String]("secret") + def lastError = column[String]("last_error") + + override def * = + (id.?, createdAt.?, (projectId, publicId, name, callbackUrl, discordFormatted, eventTypes, secret, lastError.?)).<>( + mkApply((Webhook.apply _).tupled), + mkUnapply(Webhook.unapply) + ) +} diff --git a/models/src/main/scala/ore/models/Job.scala b/models/src/main/scala/ore/models/Job.scala index faef189ad..6ec6f0385 100644 --- a/models/src/main/scala/ore/models/Job.scala +++ b/models/src/main/scala/ore/models/Job.scala @@ -5,9 +5,11 @@ import java.time.OffsetDateTime import ore.db.impl.DefaultModelCompanion import ore.db.impl.schema.JobTable import ore.db.{DbRef, ModelQuery} -import ore.models.project.{Project, Version} +import ore.models.project.{Project, Version, Webhook} import enumeratum.values._ +import cats.syntax.all._ +import io.circe.Json import slick.lifted.TableQuery case class JobInfo( @@ -58,7 +60,13 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { } object JobType extends StringEnum[JobType] { override def values: IndexedSeq[JobType] = - IndexedSeq(UpdateDiscourseProjectTopic, UpdateDiscourseVersionPost, DeleteDiscourseTopic, PostDiscourseReply) + IndexedSeq( + UpdateDiscourseProjectTopic, + UpdateDiscourseVersionPost, + DeleteDiscourseTopic, + PostDiscourseReply, + PostWebhookResponse + ) } sealed trait TypedJob { @@ -170,4 +178,84 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { } yield PostDiscourseReply(info, topicId, poster, content) } } + + case class PostWebhookResponse( + info: JobInfo, + projectOwner: String, + projectSlug: String, + webhookId: DbRef[Webhook], + webhookSecret: String, + callbackUrl: String, + webhookType: Webhook.WebhookEventType, + data: Json + ) extends TypedJob { + override def toJob: Job = + Job( + info, + Map( + "project_owner" -> projectOwner, + "project_slug" -> projectSlug, + "webhook_id" -> webhookId.toString, + "webhook_secret" -> webhookSecret, + "webhook_callback" -> callbackUrl, + "webhook_type" -> webhookType.value, + "webhook_data" -> data.noSpaces + ) + ) + + override def withoutError: TypedJob = copy(info = info.withoutError) + } + object PostWebhookResponse extends JobType("post_webhook") { + def newJob( + projectOwner: String, + projectSlug: String, + webhookId: DbRef[Webhook], + webhookSecret: String, + callbackUrl: String, + webhookType: Webhook.WebhookEventType, + data: Json + ): PostWebhookResponse = + PostWebhookResponse( + JobInfo.newJob(this), + projectOwner, + projectSlug, + webhookId, + webhookSecret, + callbackUrl, + webhookType, + data + ) + + override type CaseClass = PostWebhookResponse + + override def toCaseClass(info: JobInfo, properties: Map[String, String]): Either[String, PostWebhookResponse] = + for { + projectOwner <- properties.get("project_owner").toRight("No project owner found") + projectSlug <- properties.get("project_slug").toRight("No project slug found") + webhookId <- properties + .get("webhook_id") + .toRight("No webhook id found") + .flatMap(_.toLongOption.toRight("Invalid webhook id")) + webhookSecret <- properties.get("webhook_secret").toRight("No webhook secret found") + callbackUrl <- properties.get("webhook_callback").toRight("No callback url found") + webhookType <- properties + .get("webhook_type") + .toRight("No webhook type found") + .flatMap(Webhook.WebhookEventType.withValueEither(_).leftMap(_.getMessage())) + data <- properties + .get("webhook_data") + .toRight("No webhook data found") + .flatMap(s => io.circe.parser.parse(s).leftMap(_.show)) + } yield PostWebhookResponse( + info, + projectOwner, + projectSlug, + webhookId, + webhookSecret, + callbackUrl, + webhookType, + data + ) + + } } diff --git a/models/src/main/scala/ore/models/project/Webhook.scala b/models/src/main/scala/ore/models/project/Webhook.scala new file mode 100644 index 000000000..a13692434 --- /dev/null +++ b/models/src/main/scala/ore/models/project/Webhook.scala @@ -0,0 +1,43 @@ +package ore.models.project + +import java.util.UUID + +import ore.db.{DbRef, ModelQuery} +import ore.db.impl.DefaultModelCompanion +import ore.db.impl.schema.WebhookTable + +import enumeratum.values.{StringEnum, StringEnumEntry} +import slick.lifted.TableQuery + +case class Webhook( + projectId: DbRef[Project], + publicId: UUID, + name: String, + callbackUrl: String, + discordFormatted: Boolean, + events: List[Webhook.WebhookEventType], + secret: String, + lastError: Option[String] +) +object Webhook extends DefaultModelCompanion[Webhook, WebhookTable](TableQuery[WebhookTable]) { + + implicit val query: ModelQuery[Webhook] = ModelQuery.from(this) + + sealed abstract class WebhookEventType(val value: String) extends StringEnumEntry + object WebhookEventType extends StringEnum[WebhookEventType] { + override def values: IndexedSeq[WebhookEventType] = findValues + + case object Ping extends WebhookEventType("ping") + case object VersionCreated extends WebhookEventType("version_created") + case object VersionChangelogEdited extends WebhookEventType("version_changelog_edited") + case object VersionEdited extends WebhookEventType("version_edited") + case object VersionVisibilityChange extends WebhookEventType("version_visibility_change") + case object VersionDeleted extends WebhookEventType("version_deleted") + case object PageCreated extends WebhookEventType("page_created") + case object PageUpdated extends WebhookEventType("page_updated") + case object PageDeleted extends WebhookEventType("page_deleted") + case object MemberAdded extends WebhookEventType("member_added") + case object MemberChanged extends WebhookEventType("member_changed") + case object MemberRemoved extends WebhookEventType("member_removed") + } +} diff --git a/models/src/main/scala/ore/permission/NamedPermission.scala b/models/src/main/scala/ore/permission/NamedPermission.scala index 00b7f2c79..d0bcc21d2 100644 --- a/models/src/main/scala/ore/permission/NamedPermission.scala +++ b/models/src/main/scala/ore/permission/NamedPermission.scala @@ -23,6 +23,7 @@ object NamedPermission extends Enum[NamedPermission] { case object EditVersion extends NamedPermission(Permission.EditVersion) case object DeleteVersion extends NamedPermission(Permission.DeleteVersion) case object EditTags extends NamedPermission(Permission.EditChannel) + case object EditWebhooks extends NamedPermission(Permission.EditWebhooks) case object CreateOrganization extends NamedPermission(Permission.CreateOrganization) case object PostAsOrganization extends NamedPermission(Permission.PostAsOrganization) diff --git a/models/src/main/scala/ore/permission/package.scala b/models/src/main/scala/ore/permission/package.scala index 722a8c26e..4c6a6c919 100644 --- a/models/src/main/scala/ore/permission/package.scala +++ b/models/src/main/scala/ore/permission/package.scala @@ -50,6 +50,7 @@ package object permission { val EditVersion = Permission(1L << 13) val DeleteVersion = Permission(1L << 14) val EditChannel = Permission(1L << 15) //To become edit tags later + val EditWebhooks = Permission(1L << 16) val CreateOrganization = Permission(1L << 20) val PostAsOrganization = Permission(1L << 21) 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/auth/src/main/scala/ore/auth/CryptoUtils.scala b/models/src/main/scala/ore/util/CryptoUtils.scala similarity index 95% rename from auth/src/main/scala/ore/auth/CryptoUtils.scala rename to models/src/main/scala/ore/util/CryptoUtils.scala index 044f8ba8e..8ef58d42a 100644 --- a/auth/src/main/scala/ore/auth/CryptoUtils.scala +++ b/models/src/main/scala/ore/util/CryptoUtils.scala @@ -1,4 +1,4 @@ -package ore.auth +package ore.util import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec @@ -41,7 +41,7 @@ object CryptoUtils { //https://stackoverflow.com/a/9855338 private val hexArray = "0123456789abcdef".toCharArray - private def bytesToHex(bytes: Array[Byte]): String = { + def bytesToHex(bytes: Array[Byte]): String = { val hexChars = new Array[Char](bytes.length * 2) var j = 0 while (j < bytes.length) { diff --git a/ore/app/OreApplicationLoader.scala b/ore/app/OreApplicationLoader.scala index 2444788e0..76a1fb0c6 100644 --- a/ore/app/OreApplicationLoader.scala +++ b/ore/app/OreApplicationLoader.scala @@ -268,6 +268,7 @@ class OreComponents(context: ApplicationLoader.Context) 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 apiV2Webhooks: apiv2.Webhooks = wire[apiv2.Webhooks] lazy val versions: Versions = wire[Versions] lazy val users: Users = wire[Users] lazy val projects: Projects = wire[Projects] @@ -283,6 +284,7 @@ class OreComponents(context: ApplicationLoader.Context) lazy val apiV2VersionsProvider: Provider[apiv2.Versions] = () => apiV2Versions lazy val apiV2PagesProvider: Provider[apiv2.Pages] = () => apiV2Pages lazy val apiV2OrganizationsProvider: Provider[apiv2.Organizations] = () => apiV2Organizations + lazy val apiV2WebhooksProvider: Provider[apiv2.Webhooks] = () => apiV2Webhooks lazy val versionsProvider: Provider[Versions] = () => versions lazy val usersProvider: Provider[Users] = () => users lazy val projectsProvider: Provider[Projects] = () => projects diff --git a/ore/app/controllers/ApiV1Controller.scala b/ore/app/controllers/ApiV1Controller.scala index 9a858a7d0..1c4e7449f 100644 --- a/ore/app/controllers/ApiV1Controller.scala +++ b/ore/app/controllers/ApiV1Controller.scala @@ -8,7 +8,6 @@ import play.api.mvc._ import controllers.sugar.Requests.AuthedProjectRequest import form.OreForms -import ore.auth.CryptoUtils import ore.db.access.ModelView import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.ProjectApiKeyTable @@ -22,6 +21,7 @@ import ore.models.user.{LoggedActionProject, LoggedActionType, User} import ore.permission.Permission import ore.permission.role.Role import ore.rest.{FakeChannel, OreRestfulApiV1, OreWrites} +import ore.util.CryptoUtils import _root_.util.syntax._ import _root_.util.{StatusZ, UserActionLogger} diff --git a/ore/conf/evolutions/default/143_add_callbacks.sql b/ore/conf/evolutions/default/143_add_callbacks.sql new file mode 100644 index 000000000..3a15f6da3 --- /dev/null +++ b/ore/conf/evolutions/default/143_add_callbacks.sql @@ -0,0 +1,20 @@ +# --- !Ups + +CREATE TABLE project_callbacks +( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, + public_id UUID NOT NULL UNIQUE, + name TEXT NOT NULL, + callback_url TEXT NOT NULL, + discord_formatted BOOLEAN NOT NULL, + events TEXT[] NOT NULL, + secret TEXT NOT NULL, + last_error TEXT +); + +# --- !Downs + +DROP TABLE project_callbacks; + diff --git a/ore/conf/ore-default-settings.conf b/ore/conf/ore-default-settings.conf index e6c9dd9c7..f69923576 100644 --- a/ore/conf/ore-default-settings.conf +++ b/ore/conf/ore-default-settings.conf @@ -165,7 +165,7 @@ ore { api { session { public-expiration = 3h - expiration = 14d + expiration = 1h check-interval = 5m } diff --git a/ore/conf/swagger-custom-mappings.yml b/ore/conf/swagger-custom-mappings.yml index a9c3adcaf..bcd7d6721 100644 --- a/ore/conf/swagger-custom-mappings.yml +++ b/ore/conf/swagger-custom-mappings.yml @@ -53,3 +53,10 @@ - type: ProjectSortingStrategy specAsParameter: - $ref: '#/components/schemas/ProjectSortingStrategy' +- type: ore\.models\.project\.Webhook\.WebhookEventType + specAsParameter: + - $ref: '#/components/schemas/WebhookEventType' +- type: uuid + specAsParameter: + - type: string + format: uuid \ No newline at end of file diff --git a/ore/conf/swagger.yml b/ore/conf/swagger.yml index 5c0ba0b10..83215881f 100644 --- a/ore/conf/swagger.yml +++ b/ore/conf/swagger.yml @@ -59,6 +59,34 @@ servers: - url: /api/v2 components: + parameters: + webhookEventTypeHeader: + in: header + name: Ore-Webhook-EventType + description: Tells you what event this corresponds to without parsing the body + required: true + schema: + $ref: '#/components/schemas/WebhookEventType' + webhookTimestampHeader: + in: header + name: Ore-Webhook-Timestamp + description: When this request was sent. Use this to avoid replay attacks. + required: true + schema: + type: integer + format: unix-time + webhookSignatureHeader: + in: header + name: Ore-Webhook-HMACSignature + description: >- + A signature of the timestamp header's value, and the body. + Can be gotten through `hmac(secret, bytesOf(timestamp) ++ bytesOf(body))`. + Use this to ensure that the request is legit. + required: true + schema: + type: string + format: byte + schemas: DeployVersionInfo: description: >- @@ -149,6 +177,21 @@ components: - only_relevance - recent_downloads - recent_views + WebhookEventType: + type: string + enum: + - ping + - version_created + - version_changelog_edited + - version_edited + - version_visibility_change + - version_deleted + - page_created + - page_updated + - page_deleted + - member_added + - member_changed + - member_removed models.protocols.APIV2.Project: title: Project models.protocols.APIV2.Role: diff --git a/ore/test/db/APIV2QueriesSpec.scala b/ore/test/db/APIV2QueriesSpec.scala index bdb5b9b22..d8ceacca7 100644 --- a/ore/test/db/APIV2QueriesSpec.scala +++ b/ore/test/db/APIV2QueriesSpec.scala @@ -71,14 +71,13 @@ class APIV2QueriesSpec extends DbSpec { } test("ProjectMembers") { - check(APIV2Queries.projectMembers("Foo", "bar", 20L, 0L)) + check(APIV2Queries.projectMembers(5L, 20L, 0L)) } test("VersionCountQuery") { check( APIV2Queries.versionCountQuery( - "Foo", - "Bar", + 5L, List("Foo" -> Some("Bar"), "Baz" -> None), canSeeHidden = false, stability = List(Version.Stability.Stable), @@ -93,10 +92,10 @@ class APIV2QueriesSpec extends DbSpec { } test("ProjectStats") { - check(APIV2Queries.projectStats("foo", "bar", LocalDate.now().minusDays(30), LocalDate.now())) + check(APIV2Queries.projectStats(5L, LocalDate.now().minusDays(30), LocalDate.now())) } test("VersionStats") { - check(APIV2Queries.versionStats("foo", "bar", "baz", LocalDate.now().minusDays(30), LocalDate.now())) + check(APIV2Queries.versionStats(5L, "baz", LocalDate.now().minusDays(30), LocalDate.now())) } } diff --git a/oreClient/src/main/assets/api.js b/oreClient/src/main/assets/api.js index d64fb0747..331a47b54 100644 --- a/oreClient/src/main/assets/api.js +++ b/oreClient/src/main/assets/api.js @@ -110,7 +110,7 @@ export class API { if (data.type !== 'user') { throw new Error('Expected user session from user authentication') } else { - localStorage.setItem('api_session', JSON.stringify(data)) + sessionStorage.setItem('api_session', JSON.stringify(data)) return data.session } } else { @@ -126,7 +126,7 @@ export class API { if (data.type !== 'public') { throw new Error('Expected public session from public authentication') } else { - localStorage.setItem('public_api_session', JSON.stringify(data)) + sessionStorage.setItem('public_api_session', JSON.stringify(data)) return data.session } } @@ -138,9 +138,9 @@ export class API { let session if (this.hasUser()) { - session = parseJsonOrNull(localStorage.getItem('api_session')) + session = parseJsonOrNull(sessionStorage.getItem('api_session')) } else { - session = parseJsonOrNull(localStorage.getItem('public_api_session')) + session = parseJsonOrNull(sessionStorage.getItem('public_api_session')) } if (session !== null && !isNaN(new Date(session.expires).getTime()) && new Date(session.expires) < nowWithPadding) { @@ -156,9 +156,9 @@ export class API { static invalidateSession() { if (window.isLoggedIn) { - localStorage.removeItem('api_session') + sessionStorage.removeItem('api_session') } else { - localStorage.removeItem('public_api_session') + sessionStorage.removeItem('public_api_session') } } } diff --git a/orePlayCommon/app/controllers/sugar/Requests.scala b/orePlayCommon/app/controllers/sugar/Requests.scala index 0085e8670..f6669d6fc 100644 --- a/orePlayCommon/app/controllers/sugar/Requests.scala +++ b/orePlayCommon/app/controllers/sugar/Requests.scala @@ -2,21 +2,24 @@ package controllers.sugar import java.time.OffsetDateTime -import play.api.mvc.{Request, WrappedRequest} +import play.api.mvc.{Request, Results, WrappedRequest} import models.viewhelper._ +import ore.db.access.ModelView +import ore.db.impl.OrePostgresDriver.api._ import ore.db.{Model, ModelService} import ore.models.api.ApiKey import ore.models.organization.Organization -import ore.models.project.Project +import ore.models.project.{Project, Version} import ore.models.user.User import ore.permission.Permission -import ore.permission.scope.{GlobalScope, HasScope} +import ore.permission.scope.{GlobalScope, HasScope, OrganizationScope, ProjectScope, Scope} import ore.util.OreMDC import util.syntax._ import cats.Applicative import org.slf4j.MDC +import zio.{UIO, ZIO} /** * Contains the custom WrappedRequests used by Ore. @@ -40,8 +43,12 @@ object Requests { .getOrElse(F.pure(globalPerms)) } - case class ApiRequest[A](apiInfo: ApiAuthInfo, scopePermission: Permission, request: Request[A]) - extends WrappedRequest[A](request) + case class ApiRequest[S <: ResolvedAPIScope, A]( + apiInfo: ApiAuthInfo, + scopePermission: Permission, + scope: S, + request: Request[A] + ) extends WrappedRequest[A](request) with OreMDC { def user: Option[Model[User]] = apiInfo.user @@ -56,6 +63,30 @@ object Requests { } override def afterLog(): Unit = mdcClear() + + def project( + implicit service: ModelService[UIO], + ev: S =:= ResolvedAPIScope.ProjectScope + ): ZIO[Any, Results.Status, Model[Project]] = + ModelView.now(Project).get(ev(scope).id).toZIO.orElseFail(Results.NotFound) + + def version( + versionString: String + )( + implicit service: ModelService[UIO], + ev: S =:= ResolvedAPIScope.ProjectScope + ): ZIO[Any, Results.Status, Model[Version]] = + ModelView + .now(Version) + .find(v => v.projectId === ev(scope).id && v.versionString === versionString) + .toZIO + .orElseFail(Results.NotFound) + + def organization( + implicit service: ModelService[UIO], + ev: S =:= ResolvedAPIScope.OrganizationScope + ): ZIO[Any, Results.Status, Model[Organization]] = + ModelView.now(Organization).get(ev(scope).id).toZIO.orElseFail(Results.NotFound) } private def mdcPutUser(user: Model[User]): Unit = { diff --git a/orePlayCommon/app/controllers/sugar/ResolvedAPIScope.scala b/orePlayCommon/app/controllers/sugar/ResolvedAPIScope.scala new file mode 100644 index 000000000..4f6704864 --- /dev/null +++ b/orePlayCommon/app/controllers/sugar/ResolvedAPIScope.scala @@ -0,0 +1,24 @@ +package controllers.sugar + +import ore.db.DbRef +import ore.models.organization.Organization +import ore.models.project.Project +import ore.permission.scope.{HasScope, Scope} + +sealed abstract class ResolvedAPIScope { + def toScope: Scope +} +object ResolvedAPIScope { + case object GlobalScope extends ResolvedAPIScope { + override def toScope: ore.permission.scope.GlobalScope.type = ore.permission.scope.GlobalScope + } + case class ProjectScope(projectOwner: String, projectSlug: String, id: DbRef[Project]) extends ResolvedAPIScope { + override def toScope: ore.permission.scope.ProjectScope = + ore.permission.scope.ProjectScope(id) + } + case class OrganizationScope(organizationName: String, id: DbRef[Organization]) extends ResolvedAPIScope { + override def toScope: ore.permission.scope.OrganizationScope = ore.permission.scope.OrganizationScope(id) + } + + implicit val hasScope: HasScope[ResolvedAPIScope] = _.toScope +} diff --git a/orePlayCommon/app/db/impl/query/SharedQueries.scala b/orePlayCommon/app/db/impl/query/SharedQueries.scala index c48d49826..4dc0a7edf 100644 --- a/orePlayCommon/app/db/impl/query/SharedQueries.scala +++ b/orePlayCommon/app/db/impl/query/SharedQueries.scala @@ -1,14 +1,45 @@ package db.impl.query import ore.db.DbRef -import ore.models.project.Project +import ore.db.impl.query.DoobieOreProtocol +import ore.models.project.{Project, Webhook} import doobie.implicits._ -object SharedQueries { +object SharedQueries extends DoobieOreProtocol { val refreshHomeView: doobie.Update0 = sql"REFRESH MATERIALIZED VIEW home_projects".update def watcherStartProject(id: DbRef[Project]): doobie.Query0[Long] = sql"""SELECT p.stars FROM project_stats p WHERE p.id = $id""".query[Long] + + def addWebhookJobs( + projectId: DbRef[Project], + projectOwner: String, + projectSlug: String, + webhookEvent: Webhook.WebhookEventType, + data: String, + discordData: String + ): doobie.Update0 = + sql"""|INSERT INTO jobs (created_at, last_updated, retry_at, last_error, last_error_descriptor, state, job_type, + | job_properties) + |SELECT now(), + | NULL, + | NULL, + | NULL, + | NULL, + | 'not_started', + | 'post_webhook', + | hstore(ARRAY [ + | ['project_owner', $projectOwner], + | ['project_slug', $projectSlug], + | ['webhook_id', w.id::TEXT], + | ['webhook_secret', w.secret], + | ['webhook_type', $webhookEvent], + | ['webhook_callback', w.callback_url], + | ['webhook_data', CASE w.discord_formatted WHEN TRUE THEN $discordData ELSE $data END]]) + | FROM project_callbacks w + | WHERE w.project_id = $projectId + | AND $webhookEvent = ANY (w.event_types) + | AND w.last_error IS NULL;""".stripMargin.update } diff --git a/orePlayCommon/app/ore/WebhookJobAdder.scala b/orePlayCommon/app/ore/WebhookJobAdder.scala new file mode 100644 index 000000000..ca66e0921 --- /dev/null +++ b/orePlayCommon/app/ore/WebhookJobAdder.scala @@ -0,0 +1,40 @@ +package ore + +import _root_.db.impl.query.SharedQueries +import ore.db.{DbRef, ModelService} +import ore.models.project.{Project, Webhook} + +import ackcord.data.OutgoingEmbed +import ackcord.requests.ExecuteWebhookData +import io.circe.Encoder +import io.circe.syntax._ +import zio.UIO + +object WebhookJobAdder { + + def add[A: Encoder]( + projectId: DbRef[Project], + projectOwner: String, + projectSlug: String, + webhookEvent: Webhook.WebhookEventType, + data: A, + discordData: OutgoingEmbed + )( + implicit service: ModelService[UIO] + ): UIO[Unit] = + service + .runDbCon( + SharedQueries + .addWebhookJobs( + projectId, + projectOwner, + projectSlug, + webhookEvent, + data.asJson.noSpaces, + ExecuteWebhookData(embeds = Seq(discordData)).asJson.noSpaces + ) + .run + ) + .unit + +} diff --git a/orePlayCommon/app/util/UserActionLogger.scala b/orePlayCommon/app/util/UserActionLogger.scala index dc6d964de..65e610e9b 100644 --- a/orePlayCommon/app/util/UserActionLogger.scala +++ b/orePlayCommon/app/util/UserActionLogger.scala @@ -1,6 +1,7 @@ package util import controllers.sugar.Requests.{ApiRequest, AuthRequest} +import controllers.sugar.ResolvedAPIScope import ore.StatTracker import ore.db.{DbRef, Model, ModelQuery, ModelService} import ore.models.user.{LoggedActionCommon, LoggedActionType} @@ -38,7 +39,7 @@ object UserActionLogger { } def logApi[F[_], Ctx, M: ModelQuery]( - request: ApiRequest[_], + request: ApiRequest[_ <: ResolvedAPIScope, _], action: LoggedActionType[Ctx], ctxId: DbRef[Ctx], newState: String, @@ -47,7 +48,7 @@ object UserActionLogger { logApiOption(request, action, Some(ctxId), newState, oldState)(createAction) def logApiOption[F[_], Ctx, M: ModelQuery]( - request: ApiRequest[_], + request: ApiRequest[_ <: ResolvedAPIScope, _], action: LoggedActionType[Ctx], ctxId: Option[DbRef[Ctx]], newState: String, diff --git a/project/dependencies.scala b/project/dependencies.scala index 89b29af2c..88b613df5 100644 --- a/project/dependencies.scala +++ b/project/dependencies.scala @@ -97,6 +97,7 @@ object Deps { ).map(flexmarkDep) lazy val squealCategoryMacro = "net.katsstuff" %% "squeal-category-macro" % Version.squeal + val ackcordRequests = "net.katsstuff" %% "ackcord-requests" % "0.18.0-SNAPSHOT" val pluginMeta = "org.spongepowered" % "plugin-meta" % "0.4.1"