From 57d47002c48e7da93ed615ec9cd6373b05dcd605 Mon Sep 17 00:00:00 2001 From: Katrix Date: Tue, 24 Nov 2020 17:43:32 +0100 Subject: [PATCH 1/8] Scope changes and other misc fixes --- .../apiv2/AbstractApiV2Controller.scala | 96 +++++------ .../app/controllers/apiv2/Organizations.scala | 7 +- apiV2/app/controllers/apiv2/Pages.scala | 107 ++++++------ apiV2/app/controllers/apiv2/Permissions.scala | 18 +- apiV2/app/controllers/apiv2/Projects.scala | 61 +++---- apiV2/app/controllers/apiv2/Versions.scala | 159 +++++++----------- .../controllers/apiv2/helpers/Members.scala | 5 +- .../controllers/apiv2/helpers/apiScope.scala | 11 +- apiV2/app/db/impl/query/APIV2Queries.scala | 95 +++++------ apiV2/conf/apiv2.routes | 8 + .../scala/ore/permission/scope/scope.scala | 7 +- .../app/controllers/sugar/Requests.scala | 29 +++- orePlayCommon/app/util/UserActionLogger.scala | 5 +- 13 files changed, 291 insertions(+), 317 deletions(-) diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 443f400b3..96b77ca5c 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -83,39 +83,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 <: Scope](scope: APIScope[S]): ActionRefiner[Request, ApiRequest[S, *]] = + new ActionRefiner[Request, ApiRequest[S, *]] { + def executionContext: ExecutionContext = ec + + override protected def refine[A](request: Request[A]): Future[Either[Result, ApiRequest[S, A]]] = { + def unAuth(msg: String) = Unauthorized(ApiError(msg)).withHeaders(WWW_AUTHENTICATE -> "OreApi") + + val authRequest = for { + creds <- parseAuthHeader(request).mapError(_.toResult) + token <- ZIO + .fromOption(creds.params.get("session")) + .orElseFail(unAuth("No session specified")) + info <- service + .runDbCon(APIV2Queries.getApiAuthInfo(token).option) + .get + .orElseFail(unAuth("Invalid session")) + realScope <- apiScopeToRealScope(scope).orElseFail(NotFound) + scopePerms <- info.permissionIn(realScope) + res <- { + if (info.expires.isBefore(OffsetDateTime.now())) { + service.deleteWhere(ApiSession)(_.token === token) *> IO.fail(unAuth("Api session expired")) + } else ZIO.succeed(ApiRequest(info, scopePerms, realScope, request)) + } + } yield res + + zioToFuture(authRequest.either) + } } - } - def apiScopeToRealScope(scope: APIScope): IO[Unit, Scope] = scope match { - case APIScope.GlobalScope => UIO.succeed(GlobalScope) + def apiScopeToRealScope[S <: Scope](scope: APIScope[S]): IO[Unit, S] = scope match { + case APIScope.GlobalScope => UIO.succeed(GlobalScope.asInstanceOf[S]) case APIScope.ProjectScope(projectOwner, projectSlug) => service .runDBIO( @@ -127,7 +125,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( ) .get .orElseFail(()) - .map(ProjectScope) + .map(ProjectScope(_).asInstanceOf[S]) case APIScope.OrganizationScope(organizationName) => val q = for { u <- TableQuery[UserTable] @@ -139,14 +137,14 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( .runDBIO(q.result.headOption) .get .orElseFail(()) - .map(OrganizationScope) + .map(OrganizationScope(_).asInstanceOf[S]) } def createApiScope( projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String] - ): Either[Result, APIScope] = { + ): Either[Result, APIScope[_ <: Scope]] = { val projectOwnerName = projectOwner.zip(projectSlug) if ((projectOwner.isDefined || projectSlug.isDefined) && projectOwnerName.isEmpty) { @@ -167,27 +165,31 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( projectSlug: Option[String], organizationName: Option[String] )( - implicit request: ApiRequest[_] - ): IO[Result, (APIScope, Permission)] = + implicit request: ApiRequest[_ <: Scope, _] + ): IO[Result, (APIScope[_ <: Scope], 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 <: Scope](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 <: Scope]: 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 +206,9 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def ApiAction(perms: Permission, scope: APIScope): ActionBuilder[ApiRequest, AnyContent] = + def ApiAction[S <: Scope](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 <: Scope](perms: Permission, scope: APIScope[S]): ActionBuilder[ApiRequest[S, *], AnyContent] = ApiAction(perms, scope).andThen(cachingAction) } diff --git a/apiV2/app/controllers/apiv2/Organizations.scala b/apiV2/app/controllers/apiv2/Organizations.scala index a4eaa290f..f9f5f2dcc 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,16 +32,16 @@ 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) } 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), + getSubject = r.organization, allowOrgMembers = false, - getMembersQuery = APIV2Queries.orgaMembers(organization, _, _), + getMembersQuery = APIV2Queries.orgaMembers(r.scope.id, _, _), createRole = OrganizationUserRole(_, _, _), roleCompanion = OrganizationUserRole, notificationType = NotificationType.OrganizationInvite, diff --git a/apiV2/app/controllers/apiv2/Pages.scala b/apiV2/app/controllers/apiv2/Pages.scala index 66f39d2ae..d01ea27df 100644 --- a/apiV2/app/controllers/apiv2/Pages.scala +++ b/apiV2/app/controllers/apiv2/Pages.scala @@ -6,6 +6,7 @@ import play.api.mvc.{Action, AnyContent} import controllers.OreControllerComponents import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} +import controllers.sugar.Requests.ApiRequest import db.impl.query.APIV2Queries import models.protocols.APIV2 import ore.db.DbRef @@ -13,6 +14,7 @@ import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.PageTable import ore.models.project.{Page, Project} import ore.permission.Permission +import ore.permission.scope.ProjectScope import ore.util.StringUtils import util.PatchDecoder import util.syntax._ @@ -33,63 +35,65 @@ 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[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[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 - def insertNewPage(projectId: DbRef[Project], parentId: Option[DbRef[Page]]) = + val pageArr = page.split("/") + val pageParent = pageArr.init.mkString("/") + val slug = StringUtils.slugify(pageArr.last) //TODO: Check ASCII + + val updateExisting = getPageOpt(page).flatMap { + case (id, _, _) => + service + .runDBIO( + TableQuery[PageTable].filter(_.id === id).map(p => (p.name, p.contents)).update((newName, content)) + ) + .as(Ok(APIV2.Page(newName, content))) + } + + def insertNewPage(parentId: Option[DbRef[Page]]) = service - .insert(Page(projectId, parentId, newName, slug, isDeletable = true, content)) + .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) @@ -115,17 +119,15 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) val slug = newName.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 = + newName.parent + .map(_.map(p => getPageOpt(p).map(_._1)).sequence) + .sequence + .orElseFail(BadRequest(ApiError("Unknown parent"))) val runRename = (oldPage <&> newParent).flatMap { - case ((_, id, name, contents), parentId) => + 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)))) @@ -139,13 +141,10 @@ 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 diff --git a/apiV2/app/controllers/apiv2/Permissions.scala b/apiV2/app/controllers/apiv2/Permissions.scala index 536e3a6ff..a028da490 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], diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 3ad62532a..3c3c1d6c0 100644 --- a/apiV2/app/controllers/apiv2/Projects.scala +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -27,6 +27,7 @@ import ore.models.project.{Project, ProjectSortingStrategy, Version} import ore.models.user.role.ProjectUserRole import ore.models.user.{LoggedActionProject, LoggedActionType} import ore.permission.Permission +import ore.permission.scope.Scope import ore.util.{OreMDC, StringUtils} import util.syntax._ import util.{PartialUtils, PatchDecoder, UserActionLogger} @@ -180,7 +181,7 @@ class Projects( ) ) ) - ) + ).withHeaders("Location" -> routes.Projects.showProject(project.ownerName, project.slug).absoluteURL()) } } @@ -210,9 +211,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 +240,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 +269,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,16 +300,16 @@ 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) } 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), + getSubject = r.project, allowOrgMembers = true, - getMembersQuery = APIV2Queries.projectMembers(projectOwner, projectSlug, _, _), + getMembersQuery = APIV2Queries.projectMembers(r.scope.id, _, _), createRole = ProjectUserRole(_, _, _), roleCompanion = ProjectUserRole, notificationType = NotificationType.ProjectInvite, @@ -326,7 +323,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 +339,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 +349,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, @@ -377,7 +374,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 +390,7 @@ class Projects( ) } - private def doHardDeleteProject(project: Model[Project])(implicit request: ApiRequest[_]): UIO[Unit] = { + private def doHardDeleteProject(project: Model[Project])(implicit request: ApiRequest[_ <: Scope, _]): UIO[Unit] = { projects.delete(project).unit <* UserActionLogger.logApiOption( request, LoggedActionType.ProjectVisibilityChange, @@ -405,25 +402,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 +435,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..22d8bc61d 100644 --- a/apiV2/app/controllers/apiv2/Versions.scala +++ b/apiV2/app/controllers/apiv2/Versions.scala @@ -30,6 +30,7 @@ import ore.models.project.factory.ProjectFactory import ore.models.project.io.{PluginFileWithData, PluginUpload, VersionedPlatform} import ore.models.user.{LoggedActionType, LoggedActionVersion, User} import ore.permission.Permission +import ore.permission.scope.{ProjectScope, Scope} import util.syntax._ import util.{PatchDecoder, UserActionLogger} @@ -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,15 +208,14 @@ 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) @@ -233,14 +229,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,8 +245,7 @@ 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 @@ -276,7 +269,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 +285,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 +298,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[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 +307,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 +323,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 +378,7 @@ class Versions( .mapError(e => BadRequest(ApiError(e))) for { - t <- processVersionUploadToErrors(projectOwner, projectSlug) + t <- processVersionUploadToErrors (user, project, pluginFile) = t data <- dataF t <- factory @@ -424,73 +417,55 @@ class Versions( version.postId ) - Created(apiVersion.asProtocol) + 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 - - log *> projects.deleteVersion(version).as(NoContent) - } + request.version(version).flatMap { version => + val log = UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + "", + "" + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + + log *> projects.deleteVersion(version).as(NoContent) + } } def setVersionVisibility(projectOwner: String, projectSlug: String, version: String): Action[EditVisibility] = ApiAction(Permission.None, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[EditVisibility]) { implicit request => - 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 => + request.body.process( + version, + request.user.get.id, + request.scopePermission, + Permission.DeleteVersion, + service.insert(Job.UpdateDiscourseVersionPost.newJob(version.id).toJob).unit, + projects.deleteVersion(_: Model[Version]).unit, + (newV, oldV) => + UserActionLogger + .logApi( + request, + LoggedActionType.VersionDeleted, + version.id, + newV, + oldV + )(LoggedActionVersion(_, Some(version.projectId))) + .unit + ) + } } def editDiscourseSettings( @@ -500,22 +475,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/helpers/Members.scala b/apiV2/app/controllers/apiv2/helpers/Members.scala index 99f712cf4..e4669644f 100644 --- a/apiV2/app/controllers/apiv2/helpers/Members.scala +++ b/apiV2/app/controllers/apiv2/helpers/Members.scala @@ -27,6 +27,7 @@ import ore.db.impl.table.common.RoleTable import ore.member.MembershipDossier import ore.models.organization.Organization import ore.permission.role.Role +import ore.permission.scope.Scope import io.circe._ import io.circe.derivation.annotations.SnakeCaseJsonCodec @@ -49,7 +50,7 @@ object Members { limit: Option[Long], offset: Long )( - implicit r: ApiRequest[_], + implicit r: ApiRequest[_ <: Scope, _], service: ModelService[UIO], writeJson: Writeable[Json] ): ZIO[Any, Nothing, Result] = { @@ -76,7 +77,7 @@ object Members { notificationType: NotificationType, notificationLocalization: String )( - implicit r: ApiRequest[List[MemberUpdate]], + implicit r: ApiRequest[_ <: Scope, List[MemberUpdate]], service: ModelService[UIO], users: UserBase[UIO], memberships: MembershipDossier.Aux[UIO, A, R, RT], diff --git a/apiV2/app/controllers/apiv2/helpers/apiScope.scala b/apiV2/app/controllers/apiv2/helpers/apiScope.scala index 321bb7f9d..c9d6a0fac 100644 --- a/apiV2/app/controllers/apiv2/helpers/apiScope.scala +++ b/apiV2/app/controllers/apiv2/helpers/apiScope.scala @@ -3,15 +3,18 @@ package controllers.apiv2.helpers import scala.collection.immutable 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 <: Scope](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[ore.permission.scope.GlobalScope.type](APIScopeType.Global) + case class ProjectScope(projectOwner: String, projectSlug: String) + extends APIScope[ore.permission.scope.ProjectScope](APIScopeType.Project) + case class OrganizationScope(organizationName: String) + extends APIScope[ore.permission.scope.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..4346cd470 100644 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ b/apiV2/app/db/impl/query/APIV2Queries.scala @@ -8,6 +8,7 @@ import controllers.apiv2.Users.UserSortingStrategy import controllers.apiv2.{Pages, Projects, Users, Versions} 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 +346,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 +373,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 +450,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 +471,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 +482,7 @@ object APIV2Queries extends DoobieOreProtocol { offset: Long )(implicit config: OreConfig): Query0[APIV2.Version] = (versionSelectFrag( - projectOwner, - projectSlug, + projectId, versionName, platforms, stability, @@ -496,17 +494,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 +510,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 +523,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 +751,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 +760,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 +790,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], diff --git a/apiV2/conf/apiv2.routes b/apiV2/conf/apiv2.routes index bc6e8f0c2..989855e7c 100644 --- a/apiV2/conf/apiv2.routes +++ b/apiV2/conf/apiv2.routes @@ -1062,6 +1062,14 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ ### 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/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/orePlayCommon/app/controllers/sugar/Requests.scala b/orePlayCommon/app/controllers/sugar/Requests.scala index 0085e8670..8342b9d88 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,7 +43,7 @@ object Requests { .getOrElse(F.pure(globalPerms)) } - case class ApiRequest[A](apiInfo: ApiAuthInfo, scopePermission: Permission, request: Request[A]) + case class ApiRequest[S <: Scope, 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 +59,24 @@ object Requests { } override def afterLog(): Unit = mdcClear() + + def project(implicit service: ModelService[UIO], ev: S =:= 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 =:= 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 =:= 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/util/UserActionLogger.scala b/orePlayCommon/app/util/UserActionLogger.scala index dc6d964de..dac28d31b 100644 --- a/orePlayCommon/app/util/UserActionLogger.scala +++ b/orePlayCommon/app/util/UserActionLogger.scala @@ -4,6 +4,7 @@ import controllers.sugar.Requests.{ApiRequest, AuthRequest} import ore.StatTracker import ore.db.{DbRef, Model, ModelQuery, ModelService} import ore.models.user.{LoggedActionCommon, LoggedActionType} +import ore.permission.scope.Scope import com.github.tminglei.slickpg.InetString @@ -38,7 +39,7 @@ object UserActionLogger { } def logApi[F[_], Ctx, M: ModelQuery]( - request: ApiRequest[_], + request: ApiRequest[_ <: Scope, _], 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[_ <: Scope, _], action: LoggedActionType[Ctx], ctxId: Option[DbRef[Ctx]], newState: String, From 155767794878f4c8cb8f95c4d3a11a5e91e71b5a Mon Sep 17 00:00:00 2001 From: Katrix Date: Tue, 24 Nov 2020 22:05:49 +0100 Subject: [PATCH 2/8] Fix tests --- ore/test/db/APIV2QueriesSpec.scala | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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())) } } From b298728e30ff34b411458be05004e7650ebfc618 Mon Sep 17 00:00:00 2001 From: Katrix Date: Wed, 25 Nov 2020 17:25:44 +0100 Subject: [PATCH 3/8] Create the scope to test against before processing the request also for hasAll/any requests --- .../apiv2/AbstractApiV2Controller.scala | 15 +------------ apiV2/app/controllers/apiv2/Permissions.scala | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 96b77ca5c..20b0b15da 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -10,7 +10,7 @@ import play.api.mvc._ import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} import controllers.sugar.CircePlayController -import controllers.sugar.Requests.{ApiAuthInfo, ApiRequest} +import controllers.sugar.Requests.ApiRequest import controllers.{OreBaseController, OreControllerComponents} import db.impl.query.APIV2Queries import ore.db.impl.OrePostgresDriver.api._ @@ -160,19 +160,6 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def permissionsInApiScope( - projectOwner: Option[String], - projectSlug: Option[String], - organizationName: Option[String] - )( - implicit request: ApiRequest[_ <: Scope, _] - ): IO[Result, (APIScope[_ <: Scope], Permission)] = - for { - apiScope <- ZIO.fromEither(createApiScope(projectOwner, projectSlug, organizationName)) - scope <- apiScopeToRealScope(apiScope).orElseFail(NotFound) - perms <- request.permissionIn(scope) - } yield (apiScope, perms) - def permApiAction[S <: Scope](perms: Permission): ActionFilter[ApiRequest[S, *]] = new ActionFilter[ApiRequest[S, *]] { override protected def executionContext: ExecutionContext = ec diff --git a/apiV2/app/controllers/apiv2/Permissions.scala b/apiV2/app/controllers/apiv2/Permissions.scala index a028da490..448ea05c8 100644 --- a/apiV2/app/controllers/apiv2/Permissions.scala +++ b/apiV2/app/controllers/apiv2/Permissions.scala @@ -48,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], @@ -62,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], From aa7d6fa4425760fd72075040879a3884eaa0978e Mon Sep 17 00:00:00 2001 From: Katrix Date: Wed, 25 Nov 2020 17:26:15 +0100 Subject: [PATCH 4/8] Use sessionStorage and lower expirations for sessions --- ore/conf/ore-default-settings.conf | 2 +- oreClient/src/main/assets/api.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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/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') } } } From 6852c4de7732c72e354bb1ac9f42f6442519a8be Mon Sep 17 00:00:00 2001 From: Katrix Date: Wed, 25 Nov 2020 22:19:34 +0100 Subject: [PATCH 5/8] Switch to a new scope type which also includes the request path params --- .../apiv2/AbstractApiV2Controller.scala | 35 +++++++++++-------- apiV2/app/controllers/apiv2/Pages.scala | 10 ++++-- apiV2/app/controllers/apiv2/Projects.scala | 8 +++-- apiV2/app/controllers/apiv2/Versions.scala | 4 +-- .../controllers/apiv2/helpers/Members.scala | 6 ++-- .../controllers/apiv2/helpers/apiScope.scala | 9 ++--- .../app/controllers/sugar/Requests.scala | 20 ++++++++--- .../controllers/sugar/ResolvedAPIScope.scala | 24 +++++++++++++ orePlayCommon/app/util/UserActionLogger.scala | 6 ++-- 9 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 orePlayCommon/app/controllers/sugar/ResolvedAPIScope.scala diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 20b0b15da..4c6960802 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -9,7 +9,7 @@ import play.api.inject.ApplicationLifecycle import play.api.mvc._ import controllers.apiv2.helpers.{APIScope, ApiError, ApiErrors} -import controllers.sugar.CircePlayController +import controllers.sugar.{CircePlayController, ResolvedAPIScope} import controllers.sugar.Requests.ApiRequest import controllers.{OreBaseController, OreControllerComponents} import db.impl.query.APIV2Queries @@ -17,7 +17,6 @@ import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.{OrganizationTable, ProjectTable, UserTable} import ore.models.api.ApiSession import ore.permission.Permission -import ore.permission.scope.{GlobalScope, OrganizationScope, ProjectScope, Scope} import akka.http.scaladsl.model.ErrorInfo import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials} @@ -83,7 +82,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } yield res } - def apiAction[S <: Scope](scope: APIScope[S]): ActionRefiner[Request, ApiRequest[S, *]] = + def apiAction[S <: ResolvedAPIScope](scope: APIScope[S]): ActionRefiner[Request, ApiRequest[S, *]] = new ActionRefiner[Request, ApiRequest[S, *]] { def executionContext: ExecutionContext = ec @@ -99,12 +98,12 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( .runDbCon(APIV2Queries.getApiAuthInfo(token).option) .get .orElseFail(unAuth("Invalid session")) - realScope <- apiScopeToRealScope(scope).orElseFail(NotFound) - scopePerms <- info.permissionIn(realScope) + 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, realScope, request)) + } else ZIO.succeed(ApiRequest(info, scopePerms, resolvedScope, request)) } } yield res @@ -112,8 +111,8 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def apiScopeToRealScope[S <: Scope](scope: APIScope[S]): IO[Unit, S] = scope match { - case APIScope.GlobalScope => UIO.succeed(GlobalScope.asInstanceOf[S]) + 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( @@ -125,7 +124,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( ) .get .orElseFail(()) - .map(ProjectScope(_).asInstanceOf[S]) + .map(ResolvedAPIScope.ProjectScope(projectOwner, projectSlug, _).asInstanceOf[S]) case APIScope.OrganizationScope(organizationName) => val q = for { u <- TableQuery[UserTable] @@ -137,14 +136,14 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( .runDBIO(q.result.headOption) .get .orElseFail(()) - .map(OrganizationScope(_).asInstanceOf[S]) + .map(ResolvedAPIScope.OrganizationScope(organizationName, _).asInstanceOf[S]) } def createApiScope( projectOwner: Option[String], projectSlug: Option[String], organizationName: Option[String] - ): Either[Result, APIScope[_ <: Scope]] = { + ): Either[Result, APIScope[_ <: ResolvedAPIScope]] = { val projectOwnerName = projectOwner.zip(projectSlug) if ((projectOwner.isDefined || projectSlug.isDefined) && projectOwnerName.isEmpty) { @@ -160,7 +159,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def permApiAction[S <: Scope](perms: Permission): ActionFilter[ApiRequest[S, *]] = + def permApiAction[S <: ResolvedAPIScope](perms: Permission): ActionFilter[ApiRequest[S, *]] = new ActionFilter[ApiRequest[S, *]] { override protected def executionContext: ExecutionContext = ec @@ -169,7 +168,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( else Future.successful(Some(Forbidden)) } - def cachingAction[S <: Scope]: ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] = + def cachingAction[S <: ResolvedAPIScope]: ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] = new ActionFunction[ApiRequest[S, *], ApiRequest[S, *]] { override protected def executionContext: ExecutionContext = ec @@ -193,9 +192,15 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( } } - def ApiAction[S <: Scope](perms: Permission, scope: APIScope[S]): ActionBuilder[ApiRequest[S, *], AnyContent] = + def ApiAction[S <: ResolvedAPIScope]( + perms: Permission, + scope: APIScope[S] + ): ActionBuilder[ApiRequest[S, *], AnyContent] = Action.andThen(apiAction(scope)).andThen(permApiAction(perms)) - def CachingApiAction[S <: Scope](perms: Permission, scope: APIScope[S]): ActionBuilder[ApiRequest[S, *], AnyContent] = + def CachingApiAction[S <: ResolvedAPIScope]( + perms: Permission, + scope: APIScope[S] + ): ActionBuilder[ApiRequest[S, *], AnyContent] = ApiAction(perms, scope).andThen(cachingAction) } diff --git a/apiV2/app/controllers/apiv2/Pages.scala b/apiV2/app/controllers/apiv2/Pages.scala index d01ea27df..75177c8d4 100644 --- a/apiV2/app/controllers/apiv2/Pages.scala +++ b/apiV2/app/controllers/apiv2/Pages.scala @@ -7,6 +7,7 @@ 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 @@ -14,7 +15,6 @@ import ore.db.impl.OrePostgresDriver.api._ import ore.db.impl.schema.PageTable import ore.models.project.{Page, Project} import ore.permission.Permission -import ore.permission.scope.ProjectScope import ore.util.StringUtils import util.PatchDecoder import util.syntax._ @@ -44,14 +44,18 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) private def getPageOpt( page: String - )(implicit request: ApiRequest[ProjectScope, _]): ZIO[Any, Option[Nothing], (DbRef[Page], String, Option[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[ProjectScope, _]): ZIO[Any, Status, (DbRef[Page], String, Option[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] = diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 3c3c1d6c0..337864314 100644 --- a/apiV2/app/controllers/apiv2/Projects.scala +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -12,6 +12,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 @@ -27,7 +28,6 @@ import ore.models.project.{Project, ProjectSortingStrategy, Version} import ore.models.user.role.ProjectUserRole import ore.models.user.{LoggedActionProject, LoggedActionType} import ore.permission.Permission -import ore.permission.scope.Scope import ore.util.{OreMDC, StringUtils} import util.syntax._ import util.{PartialUtils, PatchDecoder, UserActionLogger} @@ -188,7 +188,7 @@ class Projects( //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(())) @@ -390,7 +390,9 @@ class Projects( ) } - private def doHardDeleteProject(project: Model[Project])(implicit request: ApiRequest[_ <: Scope, _]): UIO[Unit] = { + private def doHardDeleteProject( + project: Model[Project] + )(implicit request: ApiRequest[_ <: ResolvedAPIScope, _]): UIO[Unit] = { projects.delete(project).unit <* UserActionLogger.logApiOption( request, LoggedActionType.ProjectVisibilityChange, diff --git a/apiV2/app/controllers/apiv2/Versions.scala b/apiV2/app/controllers/apiv2/Versions.scala index 22d8bc61d..24a590dd4 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} @@ -30,7 +31,6 @@ import ore.models.project.factory.ProjectFactory import ore.models.project.io.{PluginFileWithData, PluginUpload, VersionedPlatform} import ore.models.user.{LoggedActionType, LoggedActionVersion, User} import ore.permission.Permission -import ore.permission.scope.{ProjectScope, Scope} import util.syntax._ import util.{PatchDecoder, UserActionLogger} @@ -299,7 +299,7 @@ class Versions( } private def processVersionUploadToErrors( - implicit request: ApiRequest[ProjectScope, MultipartFormData[Files.TemporaryFile]] + 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"))) diff --git a/apiV2/app/controllers/apiv2/helpers/Members.scala b/apiV2/app/controllers/apiv2/helpers/Members.scala index e4669644f..c1b8846e4 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 @@ -27,7 +28,6 @@ import ore.db.impl.table.common.RoleTable import ore.member.MembershipDossier import ore.models.organization.Organization import ore.permission.role.Role -import ore.permission.scope.Scope import io.circe._ import io.circe.derivation.annotations.SnakeCaseJsonCodec @@ -50,7 +50,7 @@ object Members { limit: Option[Long], offset: Long )( - implicit r: ApiRequest[_ <: Scope, _], + implicit r: ApiRequest[_ <: ResolvedAPIScope, _], service: ModelService[UIO], writeJson: Writeable[Json] ): ZIO[Any, Nothing, Result] = { @@ -77,7 +77,7 @@ object Members { notificationType: NotificationType, notificationLocalization: String )( - implicit r: ApiRequest[_ <: Scope, List[MemberUpdate]], + implicit r: ApiRequest[_ <: ResolvedAPIScope, List[MemberUpdate]], service: ModelService[UIO], users: UserBase[UIO], memberships: MembershipDossier.Aux[UIO, A, R, RT], diff --git a/apiV2/app/controllers/apiv2/helpers/apiScope.scala b/apiV2/app/controllers/apiv2/helpers/apiScope.scala index c9d6a0fac..40e646302 100644 --- a/apiV2/app/controllers/apiv2/helpers/apiScope.scala +++ b/apiV2/app/controllers/apiv2/helpers/apiScope.scala @@ -2,19 +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[RealScope <: Scope](val tpe: APIScopeType) +sealed abstract class APIScope[RealScope <: ResolvedAPIScope](val tpe: APIScopeType) object APIScope { - case object GlobalScope extends APIScope[ore.permission.scope.GlobalScope.type](APIScopeType.Global) + case object GlobalScope extends APIScope[ResolvedAPIScope.GlobalScope.type](APIScopeType.Global) case class ProjectScope(projectOwner: String, projectSlug: String) - extends APIScope[ore.permission.scope.ProjectScope](APIScopeType.Project) + extends APIScope[ResolvedAPIScope.ProjectScope](APIScopeType.Project) case class OrganizationScope(organizationName: String) - extends APIScope[ore.permission.scope.OrganizationScope](APIScopeType.Organization) + extends APIScope[ResolvedAPIScope.OrganizationScope](APIScopeType.Organization) } sealed abstract class APIScopeType extends EnumEntry with EnumEntry.Snakecase diff --git a/orePlayCommon/app/controllers/sugar/Requests.scala b/orePlayCommon/app/controllers/sugar/Requests.scala index 8342b9d88..f6669d6fc 100644 --- a/orePlayCommon/app/controllers/sugar/Requests.scala +++ b/orePlayCommon/app/controllers/sugar/Requests.scala @@ -43,8 +43,12 @@ object Requests { .getOrElse(F.pure(globalPerms)) } - case class ApiRequest[S <: Scope, A](apiInfo: ApiAuthInfo, scopePermission: Permission, scope: S, 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 @@ -60,12 +64,18 @@ object Requests { override def afterLog(): Unit = mdcClear() - def project(implicit service: ModelService[UIO], ev: S =:= ProjectScope): ZIO[Any, Results.Status, Model[Project]] = + 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 =:= ProjectScope): ZIO[Any, Results.Status, Model[Version]] = + )( + 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) @@ -74,7 +84,7 @@ object Requests { def organization( implicit service: ModelService[UIO], - ev: S =:= OrganizationScope + ev: S =:= ResolvedAPIScope.OrganizationScope ): ZIO[Any, Results.Status, Model[Organization]] = ModelView.now(Organization).get(ev(scope).id).toZIO.orElseFail(Results.NotFound) } 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/util/UserActionLogger.scala b/orePlayCommon/app/util/UserActionLogger.scala index dac28d31b..65e610e9b 100644 --- a/orePlayCommon/app/util/UserActionLogger.scala +++ b/orePlayCommon/app/util/UserActionLogger.scala @@ -1,10 +1,10 @@ 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} -import ore.permission.scope.Scope import com.github.tminglei.slickpg.InetString @@ -39,7 +39,7 @@ object UserActionLogger { } def logApi[F[_], Ctx, M: ModelQuery]( - request: ApiRequest[_ <: Scope, _], + request: ApiRequest[_ <: ResolvedAPIScope, _], action: LoggedActionType[Ctx], ctxId: DbRef[Ctx], newState: String, @@ -48,7 +48,7 @@ object UserActionLogger { logApiOption(request, action, Some(ctxId), newState, oldState)(createAction) def logApiOption[F[_], Ctx, M: ModelQuery]( - request: ApiRequest[_ <: Scope, _], + request: ApiRequest[_ <: ResolvedAPIScope, _], action: LoggedActionType[Ctx], ctxId: Option[DbRef[Ctx]], newState: String, From 6a55719cb895bc9c213dc7bc5f3925c230fbbce3 Mon Sep 17 00:00:00 2001 From: Katrix Date: Sat, 21 Nov 2020 02:22:45 +0100 Subject: [PATCH 6/8] Add initial work for webhooks --- apiV2/app/controllers/apiv2/Projects.scala | 137 +++++++++++- apiV2/app/db/impl/query/APIV2Queries.scala | 15 ++ apiV2/app/models/protocols/APIV2.scala | 31 +++ apiV2/conf/apiv2.routes | 209 ++++++++++++++++++ .../scala/ore/db/impl/OrePostgresDriver.scala | 12 +- .../ore/db/impl/query/DoobieOreProtocol.scala | 4 +- .../ore/db/impl/schema/WebhookTable.scala | 23 ++ .../scala/ore/models/project/Webhook.scala | 41 ++++ .../ore/permission/NamedPermission.scala | 1 + .../main/scala/ore/permission/package.scala | 1 + .../evolutions/default/143_add_callbacks.sql | 18 ++ 11 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 models/src/main/scala/ore/db/impl/schema/WebhookTable.scala create mode 100644 models/src/main/scala/ore/models/project/Webhook.scala create mode 100644 ore/conf/evolutions/default/143_add_callbacks.sql diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 337864314..7ea61dad0 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 @@ -21,10 +22,13 @@ import ore.OreConfig import ore.data.project.Category import ore.data.user.notification.NotificationType import ore.db.Model +import ore.db.access.ModelView import ore.db.impl.schema.ProjectRoleTable +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 @@ -38,10 +42,11 @@ 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, @@ -444,6 +449,107 @@ class Projects( .orElseFail(NotFound) } } + + def createWebhook(projectOwner: String, projectSlug: String): Action[Projects.CreateWebhookRequest] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)) + .asyncF(parseCirce.decodeJson[Projects.CreateWebhookRequest]) { request => + val data = request.body + + val publicId = UUID.randomUUID() + //TODO: Sanitize callback url + + service + .insert( + ModelWebhook( + request.scope.id, + publicId, + data.name, + data.callbackUrl, + data.discordFormatted.getOrElse(false), + data.events.toList + ) + ) + .as( + Created( + APIV2.Webhook( + publicId, + data.name, + data.callbackUrl, + data.discordFormatted.getOrElse(false), + data.events + ) + ) + ) + } + + 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(ModelWebhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) + } yield Ok( + APIV2.Webhook( + uuidWebhookId, + webhook.name, + webhook.callbackUrl, + webhook.discordFormatted, + webhook.events + ) + ) + } + + def editWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[Json] = + ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF(parseCirce.json) { + request => + IO(UUID.fromString(webhookId)).orElseFail(BadRequest).flatMap { uuidWebhookId => + val webhookEditsValidated: ValidatedNel[Error, EditableWebhook] = + EditableWebhookF.patchDecoder.traverseKC(PartialUtils.decodeAll(request.body.hcursor)) + + webhookEditsValidated match { + case Validated.Valid(webhookEdits) => + if (webhookEdits.callbackUrl.exists(callbackUrl => + webhookEdits.discordFormatted.exists(discordFormatted => callbackUrl.isEmpty && !discordFormatted) + )) + ZIO.fail(BadRequest(ApiError("Can't both set callback url to null, and discord formatted to false"))) + else { + + val withDiscordCallbackFixed = webhookEdits.copy( + callbackUrl = + if (webhookEdits.discordFormatted.contains(true)) Some(None) else webhookEdits.callbackUrl, + discordFormatted = webhookEdits.callbackUrl.flatten.map(_ => true) + ) + + val update = service.runDbCon(APIV2Queries.updateWebhook(uuidWebhookId, withDiscordCallbackFixed).run) + + //We need two queries two queries as we use the generic update function + val get = + ModelView.now(ModelWebhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound).map { + webhook => + Ok( + APIV2.Webhook( + uuidWebhookId, + webhook.name, + webhook.callbackUrl, + webhook.discordFormatted, + webhook.events + ) + ) + } + + update *> get + } + case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e.map(_.show)))) + } + } + } + + 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(ModelWebhook)(_.publicId === uuidWebhookId) + } yield NoContent + } } object Projects { import APIV2.{categoryCodec, visibilityCodec, permissionRoleCodec} @@ -547,4 +653,31 @@ object Projects { postId: Option[Int], updateTopic: Boolean ) + + import APIV2.webhookEventTypeCodec + + @SnakeCaseJsonCodec case class CreateWebhookRequest( + name: String, + callbackUrl: Option[String], + discordFormatted: Option[Boolean], + events: Seq[WebhookEventType] + ) + + type EditableWebhook = EditableWebhookF[Option] + case class EditableWebhookF[F[_]]( + name: F[String], + callbackUrl: F[Option[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/db/impl/query/APIV2Queries.scala b/apiV2/app/db/impl/query/APIV2Queries.scala index 4346cd470..a7eb40876 100644 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ b/apiV2/app/db/impl/query/APIV2Queries.scala @@ -1,6 +1,7 @@ package db.impl.query import java.time.LocalDate +import java.util.UUID import play.api.mvc.RequestHeader @@ -842,4 +843,18 @@ object APIV2Queries extends DoobieOreProtocol { (sql"UPDATE project_pages " ++ sets ++ fr"WHERE id = $id").update } + def updateWebhook(publicWebhookId: UUID, edits: Projects.EditableWebhook): Update0 = { + val webhookColumns = Projects.EditableWebhookF[Column]( + Column.arg("name"), + Column.opt("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..f587e64dd 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,34 @@ object APIV2 { slug: Seq[String], navigational: Boolean ) + + @SnakeCaseJsonCodec case class PageListEntryWithContent( + name: Seq[String], + slug: Seq[String], + navigational: Boolean, + content: Option[String] + ) + + @SnakeCaseJsonCodec case class StandaloneVisibility( + Visibility: Visibility + ) + + @SnakeCaseJsonCodec case class StandaloneUser( + user: String + ) + + @SnakeCaseJsonCodec case class StandaloneStandaloneVersionName( + name: String + ) + + @SnakeCaseJsonCodec case class Webhook( + id: UUID, + name: String, + callbackUrl: Option[String], + discordFormatted: Boolean, + events: Seq[ore.models.project.Webhook.WebhookEventType] + ) + + 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 989855e7c..6a46ed42e 100644 --- a/apiV2/conf/apiv2.routes +++ b/apiV2/conf/apiv2.routes @@ -1060,6 +1060,215 @@ DELETE /projects/:projectOwner/:projectSlug/_pages/*page @ #+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.Project.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: +# version_created: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# responses: +# 200: +# description: Ok +# version_changelog_edited: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' +# responses: +# 200: +# description: Ok +# version_edited: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# responses: +# 200: +# description: Ok +# version_visibility_change: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVisibility' +# responses: +# 200: +# description: Ok +# version_deleted: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVersionName' +# responses: +# 200: +# description: Ok +# page_created: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntryWithContent' +# responses: +# 200: +# description: Ok +# page_updated: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' +# responses: +# 200: +# description: Ok +# page_content_updated: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntryWithContent' +# responses: +# 200: +# description: Ok +# page_deleted: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' +# responses: +# 200: +# description: Ok +# member_added: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.Member' +# responses: +# 200: +# description: Ok +# member_changed: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.Member' +# responses: +# 200: +# description: Ok +# member_removed: +# '{$request.body#/callback_url}': +# post: +# requestBody: +# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneUser' +# responses: +# 200: +# description: Ok +### +#+nocsrf +POST /projects/:projectOwner/:projectSlug/webhooks @controllers.apiv2.Projects.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.Projects.getWebhook(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/models.protocols.APIV2.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.Projects.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.Projects.deleteWebhook(projectOwner, projectSlug, webhookId) + ### NoDocs ### GET /projects/:pluginId/*path @controllers.apiv2.Projects.redirectPluginId(pluginId, path) ### NoDocs ### diff --git a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala index d623f5eda..7e8013995 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} @@ -137,6 +137,16 @@ trait OrePostgresDriver value => utils.SimpleArrayUtils.mkString[Prompt](_.value.toString)(value) ).to(_.toList) + implicit val webhookEventTypeMapper: 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..5ed295fb4 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} @@ -225,6 +225,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 webhookEventTypeMeta: 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..5ac9667cf --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala @@ -0,0 +1,23 @@ +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") + + override def * = + (id.?, createdAt.?, (projectId, publicId, name, callbackUrl.?, discordFormatted, eventTypes)).<>( + mkApply((Webhook.apply _).tupled), + mkUnapply(Webhook.unapply) + ) +} 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..a7520f834 --- /dev/null +++ b/models/src/main/scala/ore/models/project/Webhook.scala @@ -0,0 +1,41 @@ +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: Option[String], + discordFormatted: Boolean, + events: List[Webhook.WebhookEventType] +) +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 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 PageContentUpdated extends WebhookEventType("page_content_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/ore/conf/evolutions/default/143_add_callbacks.sql b/ore/conf/evolutions/default/143_add_callbacks.sql new file mode 100644 index 000000000..4a2c1d992 --- /dev/null +++ b/ore/conf/evolutions/default/143_add_callbacks.sql @@ -0,0 +1,18 @@ +# --- !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, + name TEXT NOT NULL, + callback_url TEXT, + discord_formatted BOOLEAN NOT NULL, + events TEXT[] NOT NULL +); + +# --- !Downs + +DROP TABLE project_callbacks; + From aab02a4ad230313e6d7b4178e6a1ef8bdedaab6f Mon Sep 17 00:00:00 2001 From: Katrix Date: Thu, 26 Nov 2020 00:45:21 +0100 Subject: [PATCH 7/8] More webhook progress --- .../apiv2/AbstractApiV2Controller.scala | 23 +++++ .../app/controllers/apiv2/Organizations.scala | 22 +++-- apiV2/app/controllers/apiv2/Pages.scala | 56 ++++++++--- apiV2/app/controllers/apiv2/Projects.scala | 94 +++++++++++-------- apiV2/app/controllers/apiv2/Versions.scala | 51 +++++++--- .../apiv2/helpers/EditVisibility.scala | 11 ++- .../controllers/apiv2/helpers/Members.scala | 15 +-- apiV2/app/db/impl/query/APIV2Queries.scala | 2 +- apiV2/app/models/protocols/APIV2.scala | 41 ++++++-- apiV2/conf/apiv2.routes | 60 ++++++------ build.sbt | 6 +- jobs/src/main/scala/ore/JobsProcessor.scala | 34 +++++++ .../scala/ore/db/impl/OrePostgresDriver.scala | 5 +- .../ore/db/impl/query/DoobieOreProtocol.scala | 7 +- .../ore/db/impl/schema/WebhookTable.scala | 2 +- models/src/main/scala/ore/models/Job.scala | 61 +++++++++++- .../scala/ore/models/project/Webhook.scala | 3 +- .../evolutions/default/143_add_callbacks.sql | 2 +- .../app/db/impl/query/SharedQueries.scala | 32 ++++++- orePlayCommon/app/ore/WebhookJobAdder.scala | 39 ++++++++ project/dependencies.scala | 1 + 21 files changed, 425 insertions(+), 142 deletions(-) create mode 100644 orePlayCommon/app/ore/WebhookJobAdder.scala diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 4c6960802..4c0ef7703 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -16,12 +16,17 @@ 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.WebhookJobAdder +import ackcord.requests.CreateMessageData 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} @@ -203,4 +208,22 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( scope: APIScope[S] ): ActionBuilder[ApiRequest[S, *], AnyContent] = ApiAction(perms, scope).andThen(cachingAction) + + def addWebhookJob[A: Encoder]( + webhookEvent: Webhook.WebhookEventType, + data: A, + discordData: CreateMessageData //TODO: Replace with ExecuteWebhookData + )( + 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 f9f5f2dcc..57801c0dd 100644 --- a/apiV2/app/controllers/apiv2/Organizations.scala +++ b/apiV2/app/controllers/apiv2/Organizations.scala @@ -32,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(r.scope.id, _, _), 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 = r.organization, - allowOrgMembers = false, - getMembersQuery = APIV2Queries.orgaMembers(r.scope.id, _, _), - 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 75177c8d4..b71ee3bf6 100644 --- a/apiV2/app/controllers/apiv2/Pages.scala +++ b/apiV2/app/controllers/apiv2/Pages.scala @@ -13,7 +13,7 @@ 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 @@ -72,23 +72,32 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) val newName = StringUtils.compact(r.body.name) val content = r.body.content - val pageArr = page.split("/") + 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, _, _) => - service + 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(parentId: Option[DbRef[Page]]) = - service + 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("/")) { @@ -117,27 +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 = getPage(page) val newParent = - newName.parent + 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)))) + 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)))) @@ -154,9 +178,11 @@ class Pages(val errorHandler: HttpErrorHandler, lifecycle: ApplicationLifecycle) 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/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 7ea61dad0..4e6fb894f 100644 --- a/apiV2/app/controllers/apiv2/Projects.scala +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -36,6 +36,7 @@ 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._ @@ -305,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(r.scope.id, _, _), 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 = r.project, - allowOrgMembers = true, - getMembersQuery = APIV2Queries.projectMembers(r.scope.id, _, _), - 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( @@ -362,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( @@ -456,30 +473,36 @@ class Projects( val data = request.body val publicId = UUID.randomUUID() - //TODO: Sanitize callback url - - service - .insert( - ModelWebhook( - request.scope.id, - publicId, - data.name, - data.callbackUrl, - data.discordFormatted.getOrElse(false), - data.events.toList - ) - ) - .as( - Created( - APIV2.Webhook( + + val parsedUri = ZIO(Uri.parseAbsolute(data.callbackUrl)) + .orElseFail(BadRequest(ApiError("Invalid callback URL"))) + .filterOrFail(_.scheme == "https")(BadRequest(ApiError("Only HTTPS urls allowed"))) + .map(_.toString) + + parsedUri.flatMap { uri => + service + .insert( + ModelWebhook( + request.scope.id, publicId, data.name, - data.callbackUrl, + uri, data.discordFormatted.getOrElse(false), - data.events + data.events.toList ) ) - ) + .as( + Created( + APIV2.Webhook( + publicId, + data.name, + uri, + data.discordFormatted.getOrElse(false), + data.events + ) + ) + ) + } } def getWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[AnyContent] = @@ -512,14 +535,7 @@ class Projects( )) ZIO.fail(BadRequest(ApiError("Can't both set callback url to null, and discord formatted to false"))) else { - - val withDiscordCallbackFixed = webhookEdits.copy( - callbackUrl = - if (webhookEdits.discordFormatted.contains(true)) Some(None) else webhookEdits.callbackUrl, - discordFormatted = webhookEdits.callbackUrl.flatten.map(_ => true) - ) - - val update = service.runDbCon(APIV2Queries.updateWebhook(uuidWebhookId, withDiscordCallbackFixed).run) + val update = service.runDbCon(APIV2Queries.updateWebhook(uuidWebhookId, webhookEdits).run) //We need two queries two queries as we use the generic update function val get = @@ -658,7 +674,7 @@ object Projects { @SnakeCaseJsonCodec case class CreateWebhookRequest( name: String, - callbackUrl: Option[String], + callbackUrl: String, discordFormatted: Option[Boolean], events: Seq[WebhookEventType] ) @@ -666,7 +682,7 @@ object Projects { type EditableWebhook = EditableWebhookF[Option] case class EditableWebhookF[F[_]]( name: F[String], - callbackUrl: F[Option[String]], + callbackUrl: F[String], discordFormatted: F[Boolean], events: F[List[WebhookEventType]] ) diff --git a/apiV2/app/controllers/apiv2/Versions.scala b/apiV2/app/controllers/apiv2/Versions.scala index 24a590dd4..3cefd927d 100644 --- a/apiV2/app/controllers/apiv2/Versions.scala +++ b/apiV2/app/controllers/apiv2/Versions.scala @@ -222,6 +222,13 @@ class Versions( ) .unique ) + .tap { version => + addWebhookJob( + Webhook.WebhookEventType.VersionEdited, + version, + ??? + ) + } .map(r => Ok(WithAlerts(r, warnings = warnings))) } case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e))) @@ -252,6 +259,11 @@ class Versions( 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, @@ -394,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, @@ -416,13 +426,12 @@ class Versions( platforms.map(_.platformVersion).toList, version.postId ) - - Created(apiVersion.asProtocol).withHeaders( - "Location" -> routes.Versions - .showVersionAction(project.ownerName, project.slug, version.versionString) - .absoluteURL() - ) - } + _ <- 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] = @@ -439,7 +448,13 @@ class Versions( )(LoggedActionVersion(_, Some(version.projectId))) .unit - log *> projects.deleteVersion(version).as(NoContent) + val addWebhookJobs = addWebhookJob( + Webhook.WebhookEventType.VersionDeleted, + APIV2.StandaloneVersionName(version.versionString), + ??? + ) + + addWebhookJobs *> log *> projects.deleteVersion(version).as(NoContent) } } @@ -447,6 +462,7 @@ class Versions( ApiAction(Permission.None, APIScope.ProjectScope(projectOwner, projectSlug)) .asyncF(parseCirce.decodeJson[EditVisibility]) { implicit request => request.version(version).flatMap { version => + //TODO: Add webhook action in here request.body.process( version, request.user.get.id, @@ -454,6 +470,17 @@ class Versions( 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( 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 c1b8846e4..f71774c77 100644 --- a/apiV2/app/controllers/apiv2/helpers/Members.scala +++ b/apiV2/app/controllers/apiv2/helpers/Members.scala @@ -51,17 +51,13 @@ object Members { offset: Long )( implicit r: ApiRequest[_ <: ResolvedAPIScope, _], - service: ModelService[UIO], - writeJson: Writeable[Json] - ): ZIO[Any, Nothing, Result] = { + 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) } } @@ -82,9 +78,8 @@ object Members { 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/db/impl/query/APIV2Queries.scala b/apiV2/app/db/impl/query/APIV2Queries.scala index a7eb40876..b99d33d63 100644 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ b/apiV2/app/db/impl/query/APIV2Queries.scala @@ -846,7 +846,7 @@ object APIV2Queries extends DoobieOreProtocol { def updateWebhook(publicWebhookId: UUID, edits: Projects.EditableWebhook): Update0 = { val webhookColumns = Projects.EditableWebhookF[Column]( Column.arg("name"), - Column.opt("callback_url"), + Column.arg("callback_url"), Column.arg("discord_formatted"), Column.arg("webhook_events") ) diff --git a/apiV2/app/models/protocols/APIV2.scala b/apiV2/app/models/protocols/APIV2.scala index f587e64dd..9d811abc8 100644 --- a/apiV2/app/models/protocols/APIV2.scala +++ b/apiV2/app/models/protocols/APIV2.scala @@ -211,33 +211,62 @@ object APIV2 { navigational: Boolean ) - @SnakeCaseJsonCodec case class PageListEntryWithContent( - name: Seq[String], + @SnakeCaseJsonCodec case class PageWithSlug( + name: String, slug: Seq[String], navigational: Boolean, content: Option[String] ) - @SnakeCaseJsonCodec case class StandaloneVisibility( - Visibility: Visibility + @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 StandaloneStandaloneVersionName( + @SnakeCaseJsonCodec case class StandaloneVersionName( name: String ) @SnakeCaseJsonCodec case class Webhook( id: UUID, name: String, - callbackUrl: Option[String], + callbackUrl: String, discordFormatted: Boolean, events: Seq[ore.models.project.Webhook.WebhookEventType] ) + @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 6a46ed42e..a5195baf9 100644 --- a/apiV2/conf/apiv2.routes +++ b/apiV2/conf/apiv2.routes @@ -1089,7 +1089,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.Version' # responses: # 200: # description: Ok @@ -1097,7 +1099,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' # responses: # 200: # description: Ok @@ -1105,7 +1109,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.Version' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.Version' # responses: # 200: # description: Ok @@ -1113,7 +1119,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVisibility' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.VersionVisibilityChange' # responses: # 200: # description: Ok @@ -1121,7 +1129,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVersionName' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVersionName' # responses: # 200: # description: Ok @@ -1129,7 +1139,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntryWithContent' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.PageWithSlug' # responses: # 200: # description: Ok @@ -1137,15 +1149,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' -# responses: -# 200: -# description: Ok -# page_content_updated: -# '{$request.body#/callback_url}': -# post: -# requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntryWithContent' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.PageUpdateWithSlug' # responses: # 200: # description: Ok @@ -1153,15 +1159,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' -# responses: -# 200: -# description: Ok -# member_added: -# '{$request.body#/callback_url}': -# post: -# requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.Member' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' # responses: # 200: # description: Ok @@ -1169,15 +1169,9 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # '{$request.body#/callback_url}': # post: # requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.Member' -# responses: -# 200: -# description: Ok -# member_removed: -# '{$request.body#/callback_url}': -# post: -# requestBody: -# $ref: '#/components/schemas/models.protocols.APIV2.StandaloneUser' +# allOf: +# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' +# - $ref: '#/components/schemas/models.protocols.APIV2.MembersUpdate' # responses: # 200: # description: Ok diff --git a/build.sbt b/build.sbt index 744af8fe4..42bd4ec36 100644 --- a/build.sbt +++ b/build.sbt @@ -84,7 +84,8 @@ lazy val jobs = project Deps.scalaLogging, Deps.logback, Deps.sentry, - Deps.pureConfig + Deps.pureConfig, + Deps.ackcordRequests ) ) @@ -102,7 +103,8 @@ lazy val orePlayCommon: Project = project Deps.slickPlay, Deps.zio, Deps.zioCats, - Deps.pureConfig + Deps.pureConfig, + Deps.ackcordRequests ), aggregateReverseRoutes := Seq(ore) ) diff --git a/jobs/src/main/scala/ore/JobsProcessor.scala b/jobs/src/main/scala/ore/JobsProcessor.scala index 884438f7c..0c7a8ccfe 100644 --- a/jobs/src/main/scala/ore/JobsProcessor.scala +++ b/jobs/src/main/scala/ore/JobsProcessor.scala @@ -16,6 +16,10 @@ import ore.models.{Job, JobInfo} import akka.pattern.CircuitBreakerOpenException 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 @@ -127,6 +131,34 @@ object JobsProcessor { case Job.PostDiscourseReply(_, topicId, poster, content) => postReply(job, topicId, poster, content) + + case Job.PostWebhookResponse(_, projectOwner, projectSlug, 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 + ) + + val data = webhookExtraInfo.deepMerge(data) + + executeWebhook(job, callbackUrl, data) } } @@ -211,4 +243,6 @@ object JobsProcessor { private def postReply(job: Model[Job.TypedJob], topicId: Int, poster: String, content: String) = handleDiscourseErrors(job)(_.get.postDiscussionReply(topicId, poster, content).map(_.void)) + def executeWebhook(job: Model[Job.TypedJob], url: String, data: Json) = ??? + } diff --git a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala index 7e8013995..18c538749 100644 --- a/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala +++ b/models/src/main/scala/ore/db/impl/OrePostgresDriver.scala @@ -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,7 +140,7 @@ trait OrePostgresDriver value => utils.SimpleArrayUtils.mkString[Prompt](_.value.toString)(value) ).to(_.toList) - implicit val webhookEventTypeMapper: DriverJdbcType[List[Webhook.WebhookEventType]] = + implicit val webhookEventTypeListMapper: DriverJdbcType[List[Webhook.WebhookEventType]] = new AdvancedArrayJdbcType[Webhook.WebhookEventType]( "varchar", str => 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 5ed295fb4..817356a2d 100644 --- a/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala +++ b/models/src/main/scala/ore/db/impl/query/DoobieOreProtocol.scala @@ -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,7 +226,7 @@ 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 webhookEventTypeMeta: Meta[List[Webhook.WebhookEventType]] = + implicit val webhookEventTypeListMeta: Meta[List[Webhook.WebhookEventType]] = metaFromGetPut[List[String]].timap(_.map(Webhook.WebhookEventType.withValue))(_.map(_.value)) implicit val tagColorArrayMeta: Meta[List[TagColor]] = diff --git a/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala index 5ac9667cf..a337a2377 100644 --- a/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala @@ -16,7 +16,7 @@ class WebhookTable(tag: Tag) extends ModelTable[Webhook](tag, "project_webhooks" def eventTypes = column[List[Webhook.WebhookEventType]]("event_types") override def * = - (id.?, createdAt.?, (projectId, publicId, name, callbackUrl.?, discordFormatted, eventTypes)).<>( + (id.?, createdAt.?, (projectId, publicId, name, callbackUrl, discordFormatted, eventTypes)).<>( 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..17e0a2922 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,53 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { } yield PostDiscourseReply(info, topicId, poster, content) } } + + case class PostWebhookResponse( + info: JobInfo, + projectOwner: String, + projectSlug: String, + callbackUrl: String, + webhookType: Webhook.WebhookEventType, + data: Json + ) extends TypedJob { + override def toJob: Job = + Job( + info, + Map( + "project_owner" -> projectOwner, + "project_slug" -> projectSlug, + "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, + webhookType: Webhook.WebhookEventType, + data: Json + ): PostWebhookResponse = PostWebhookResponse(JobInfo.newJob(this), projectOwner, projectSlug, 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") + 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, 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 index a7520f834..3e7c67f72 100644 --- a/models/src/main/scala/ore/models/project/Webhook.scala +++ b/models/src/main/scala/ore/models/project/Webhook.scala @@ -13,7 +13,7 @@ case class Webhook( projectId: DbRef[Project], publicId: UUID, name: String, - callbackUrl: Option[String], + callbackUrl: String, discordFormatted: Boolean, events: List[Webhook.WebhookEventType] ) @@ -32,7 +32,6 @@ object Webhook extends DefaultModelCompanion[Webhook, WebhookTable](TableQuery[W case object VersionDeleted extends WebhookEventType("version_deleted") case object PageCreated extends WebhookEventType("page_created") case object PageUpdated extends WebhookEventType("page_updated") - case object PageContentUpdated extends WebhookEventType("page_content_updated") case object PageDeleted extends WebhookEventType("page_deleted") case object MemberAdded extends WebhookEventType("member_added") case object MemberChanged extends WebhookEventType("member_changed") diff --git a/ore/conf/evolutions/default/143_add_callbacks.sql b/ore/conf/evolutions/default/143_add_callbacks.sql index 4a2c1d992..946488a7c 100644 --- a/ore/conf/evolutions/default/143_add_callbacks.sql +++ b/ore/conf/evolutions/default/143_add_callbacks.sql @@ -7,7 +7,7 @@ CREATE TABLE project_callbacks project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, public_id UUID NOT NULL, name TEXT NOT NULL, - callback_url TEXT, + callback_url TEXT NOT NULL, discord_formatted BOOLEAN NOT NULL, events TEXT[] NOT NULL ); diff --git a/orePlayCommon/app/db/impl/query/SharedQueries.scala b/orePlayCommon/app/db/impl/query/SharedQueries.scala index c48d49826..ba662f042 100644 --- a/orePlayCommon/app/db/impl/query/SharedQueries.scala +++ b/orePlayCommon/app/db/impl/query/SharedQueries.scala @@ -1,14 +1,42 @@ 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_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);""".stripMargin.update } diff --git a/orePlayCommon/app/ore/WebhookJobAdder.scala b/orePlayCommon/app/ore/WebhookJobAdder.scala new file mode 100644 index 000000000..b67eb1acc --- /dev/null +++ b/orePlayCommon/app/ore/WebhookJobAdder.scala @@ -0,0 +1,39 @@ +package ore + +import _root_.db.impl.query.SharedQueries +import ore.db.{DbRef, ModelService} +import ore.models.project.{Project, Webhook} + +import ackcord.requests.CreateMessageData +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: CreateMessageData //TODO: Replace with ExecuteWebhookData + )( + implicit service: ModelService[UIO] + ): UIO[Unit] = + service + .runDbCon( + SharedQueries + .addWebhookJobs( + projectId, + projectOwner, + projectSlug, + webhookEvent, + data.asJson.noSpaces, + discordData.asJson.noSpaces + ) + .run + ) + .unit + +} diff --git a/project/dependencies.scala b/project/dependencies.scala index 89b29af2c..b2fd98d64 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.17.1" val pluginMeta = "org.spongepowered" % "plugin-meta" % "0.4.1" From 928b72ed157c09afc26368e22f2bc648b9900e69 Mon Sep 17 00:00:00 2001 From: Katrix Date: Thu, 26 Nov 2020 23:10:09 +0100 Subject: [PATCH 8/8] Initial serverside implementation done --- .../apiv2/AbstractApiV2Controller.scala | 4 +- apiV2/app/controllers/apiv2/Projects.scala | 129 +------- apiV2/app/controllers/apiv2/Webhooks.scala | 277 ++++++++++++++++++ apiV2/app/db/impl/query/APIV2Queries.scala | 6 +- apiV2/app/models/protocols/APIV2.scala | 4 +- apiV2/conf/apiv2.routes | 260 +++++++++++++--- auth/src/main/scala/ore/auth/AkkaSSOApi.scala | 1 + build.sbt | 7 +- jobs/src/main/scala/ore/JobsProcessor.scala | 241 +++++++++++++-- .../main/scala/ore/OreJobProcessorMain.scala | 28 +- jobs/src/main/scala/ore/OreJobsConfig.scala | 7 +- jobs/src/main/scala/ore/package.scala | 6 +- .../ore/db/impl/schema/WebhookTable.scala | 4 +- models/src/main/scala/ore/models/Job.scala | 37 ++- .../scala/ore/models/project/Webhook.scala | 5 +- .../main/scala/ore/util}/CryptoUtils.scala | 4 +- ore/app/OreApplicationLoader.scala | 2 + ore/app/controllers/ApiV1Controller.scala | 2 +- .../evolutions/default/143_add_callbacks.sql | 6 +- ore/conf/swagger-custom-mappings.yml | 7 + ore/conf/swagger.yml | 43 +++ .../app/db/impl/query/SharedQueries.scala | 5 +- orePlayCommon/app/ore/WebhookJobAdder.scala | 7 +- project/dependencies.scala | 2 +- 24 files changed, 874 insertions(+), 220 deletions(-) create mode 100644 apiV2/app/controllers/apiv2/Webhooks.scala rename {auth/src/main/scala/ore/auth => models/src/main/scala/ore/util}/CryptoUtils.scala (95%) diff --git a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala index 4c0ef7703..1e999c1ef 100644 --- a/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala +++ b/apiV2/app/controllers/apiv2/AbstractApiV2Controller.scala @@ -20,7 +20,7 @@ import ore.models.project.Webhook import ore.permission.Permission import ore.WebhookJobAdder -import ackcord.requests.CreateMessageData +import ackcord.data.OutgoingEmbed import akka.http.scaladsl.model.ErrorInfo import akka.http.scaladsl.model.headers.{Authorization, HttpCredentials} import cats.data.NonEmptyList @@ -212,7 +212,7 @@ abstract class AbstractApiV2Controller(lifecycle: ApplicationLifecycle)( def addWebhookJob[A: Encoder]( webhookEvent: Webhook.WebhookEventType, data: A, - discordData: CreateMessageData //TODO: Replace with ExecuteWebhookData + discordData: OutgoingEmbed )( implicit request: ApiRequest[ResolvedAPIScope.ProjectScope, _] ): UIO[Unit] = { diff --git a/apiV2/app/controllers/apiv2/Projects.scala b/apiV2/app/controllers/apiv2/Projects.scala index 4e6fb894f..8f3640f0d 100644 --- a/apiV2/app/controllers/apiv2/Projects.scala +++ b/apiV2/app/controllers/apiv2/Projects.scala @@ -23,7 +23,7 @@ import ore.data.project.Category import ore.data.user.notification.NotificationType import ore.db.Model import ore.db.access.ModelView -import ore.db.impl.schema.ProjectRoleTable +import ore.db.impl.schema.{ProjectRoleTable, WebhookTable} import ore.db.impl.OrePostgresDriver.api._ import ore.models.Job import ore.models.project.Webhook.WebhookEventType @@ -466,106 +466,6 @@ class Projects( .orElseFail(NotFound) } } - - def createWebhook(projectOwner: String, projectSlug: String): Action[Projects.CreateWebhookRequest] = - ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)) - .asyncF(parseCirce.decodeJson[Projects.CreateWebhookRequest]) { request => - val data = request.body - - val publicId = UUID.randomUUID() - - val parsedUri = ZIO(Uri.parseAbsolute(data.callbackUrl)) - .orElseFail(BadRequest(ApiError("Invalid callback URL"))) - .filterOrFail(_.scheme == "https")(BadRequest(ApiError("Only HTTPS urls allowed"))) - .map(_.toString) - - parsedUri.flatMap { uri => - service - .insert( - ModelWebhook( - request.scope.id, - publicId, - data.name, - uri, - data.discordFormatted.getOrElse(false), - data.events.toList - ) - ) - .as( - Created( - APIV2.Webhook( - publicId, - data.name, - uri, - data.discordFormatted.getOrElse(false), - data.events - ) - ) - ) - } - } - - 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(ModelWebhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound) - } yield Ok( - APIV2.Webhook( - uuidWebhookId, - webhook.name, - webhook.callbackUrl, - webhook.discordFormatted, - webhook.events - ) - ) - } - - def editWebhook(projectOwner: String, projectSlug: String, webhookId: String): Action[Json] = - ApiAction(Permission.EditWebhooks, APIScope.ProjectScope(projectOwner, projectSlug)).asyncF(parseCirce.json) { - request => - IO(UUID.fromString(webhookId)).orElseFail(BadRequest).flatMap { uuidWebhookId => - val webhookEditsValidated: ValidatedNel[Error, EditableWebhook] = - EditableWebhookF.patchDecoder.traverseKC(PartialUtils.decodeAll(request.body.hcursor)) - - webhookEditsValidated match { - case Validated.Valid(webhookEdits) => - if (webhookEdits.callbackUrl.exists(callbackUrl => - webhookEdits.discordFormatted.exists(discordFormatted => callbackUrl.isEmpty && !discordFormatted) - )) - ZIO.fail(BadRequest(ApiError("Can't both set callback url to null, and discord formatted to false"))) - else { - val update = service.runDbCon(APIV2Queries.updateWebhook(uuidWebhookId, webhookEdits).run) - - //We need two queries two queries as we use the generic update function - val get = - ModelView.now(ModelWebhook).find(_.publicId === uuidWebhookId).toZIO.orElseFail(NotFound).map { - webhook => - Ok( - APIV2.Webhook( - uuidWebhookId, - webhook.name, - webhook.callbackUrl, - webhook.discordFormatted, - webhook.events - ) - ) - } - - update *> get - } - case Validated.Invalid(e) => ZIO.fail(BadRequest(ApiErrors(e.map(_.show)))) - } - } - } - - 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(ModelWebhook)(_.publicId === uuidWebhookId) - } yield NoContent - } } object Projects { import APIV2.{categoryCodec, visibilityCodec, permissionRoleCodec} @@ -669,31 +569,4 @@ object Projects { postId: Option[Int], updateTopic: Boolean ) - - 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/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/db/impl/query/APIV2Queries.scala b/apiV2/app/db/impl/query/APIV2Queries.scala index b99d33d63..d68b1415b 100644 --- a/apiV2/app/db/impl/query/APIV2Queries.scala +++ b/apiV2/app/db/impl/query/APIV2Queries.scala @@ -6,7 +6,7 @@ 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 @@ -843,8 +843,8 @@ object APIV2Queries extends DoobieOreProtocol { (sql"UPDATE project_pages " ++ sets ++ fr"WHERE id = $id").update } - def updateWebhook(publicWebhookId: UUID, edits: Projects.EditableWebhook): Update0 = { - val webhookColumns = Projects.EditableWebhookF[Column]( + def updateWebhook(publicWebhookId: UUID, edits: Webhooks.EditableWebhook): Update0 = { + val webhookColumns = Webhooks.EditableWebhookF[Column]( Column.arg("name"), Column.arg("callback_url"), Column.arg("discord_formatted"), diff --git a/apiV2/app/models/protocols/APIV2.scala b/apiV2/app/models/protocols/APIV2.scala index 9d811abc8..46684ec6a 100644 --- a/apiV2/app/models/protocols/APIV2.scala +++ b/apiV2/app/models/protocols/APIV2.scala @@ -253,7 +253,9 @@ object APIV2 { name: String, callbackUrl: String, discordFormatted: Boolean, - events: Seq[ore.models.project.Webhook.WebhookEventType] + events: Seq[ore.models.project.Webhook.WebhookEventType], + lastError: Option[String], + secret: String ) @SnakeCaseJsonCodec case class WebhookPostData( diff --git a/apiV2/conf/apiv2.routes b/apiV2/conf/apiv2.routes index a5195baf9..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,11 +1053,11 @@ 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) ### @@ -1072,7 +1072,7 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # content: # application/json: # schema: -# $ref: '#/components/schemas/controllers.apiv2.Project.CreateWebhookRequest' +# $ref: '#/components/schemas/controllers.apiv2.Webhooks.CreateWebhookRequest' # responses: # 201: # description: Webhook created @@ -1085,99 +1085,231 @@ GET /projects/:projectOwner/:projectSlug/_projectData @ # 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.Version' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.VersionChangelog' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.Version' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.VersionVisibilityChange' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.StandaloneVersionName' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.PageWithSlug' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.PageUpdateWithSlug' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.PageListEntry' +# 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: -# allOf: -# - $ref: '#/components/schemas/models.protocols.APIV2.WebhookPostData' -# - $ref: '#/components/schemas/models.protocols.APIV2.MembersUpdate' +# 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.Projects.createWebhook(projectOwner, projectSlug) ++nocsrf +POST /projects/:projectOwner/:projectSlug/webhooks @controllers.apiv2.Webhooks.createWebhook(projectOwner, projectSlug) ### # summary: Show an existing webhook for this project @@ -1198,8 +1330,54 @@ POST /projects/:projectOwner/:projectSlug/webhooks # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf -GET /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Projects.getWebhook(projectOwner, projectSlug, webhookId) ++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 @@ -1228,7 +1406,7 @@ GET /projects/:projectOwner/:projectSlug/webhooks/:webhookId # type: array # nullable: true # items: -# $ref: '#/components/schemas/models.protocols.APIV2.WebhookEventType' +# $ref: '#/components/schemas/WebhookEventType' # # responses: # 200: @@ -1242,8 +1420,8 @@ GET /projects/:projectOwner/:projectSlug/webhooks/:webhookId # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf -PATCH /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Projects.editWebhook(projectOwner, projectSlug, webhookId) ++nocsrf +PATCH /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Webhooks.editWebhook(projectOwner, projectSlug, webhookId) ### # summary: Delete an existing webhook for this project @@ -1260,8 +1438,8 @@ PATCH /projects/:projectOwner/:projectSlug/webhooks/:webhookId # 403: # $ref: '#/components/responses/ForbiddenError' ### -#+nocsrf -DELETE /projects/:projectOwner/:projectSlug/webhooks/:webhookId @controllers.apiv2.Projects.deleteWebhook(projectOwner, projectSlug, webhookId) ++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) 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 42bd4ec36..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" @@ -91,7 +91,7 @@ lazy val jobs = project lazy val orePlayCommon: Project = project .enablePlugins(PlayScala) - .dependsOn(auth, models) + .dependsOn(auth) .settings( Settings.commonSettings, Settings.playCommonSettings, @@ -184,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 0c7a8ccfe..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,13 +8,31 @@ 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} @@ -27,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 @@ -49,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 ) } @@ -91,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) => @@ -132,7 +150,16 @@ object JobsProcessor { case Job.PostDiscourseReply(_, topicId, poster, content) => postReply(job, topicId, poster, content) - case Job.PostWebhookResponse(_, projectOwner, projectSlug, callbackUrl, webhookType, data) => + 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] = @@ -156,9 +183,7 @@ object JobsProcessor { "event_type" := webhookType ) - val data = webhookExtraInfo.deepMerge(data) - - executeWebhook(job, callbackUrl, data) + executeWebhook(job, webhookId, webhookType, webhookSecret, callbackUrl, data, webhookExtraInfo) } } @@ -243,6 +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)) - def executeWebhook(job: Model[Job.TypedJob], url: String, data: Json) = ??? + 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/schema/WebhookTable.scala b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala index a337a2377..9e33ad0cf 100644 --- a/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala +++ b/models/src/main/scala/ore/db/impl/schema/WebhookTable.scala @@ -14,9 +14,11 @@ class WebhookTable(tag: Tag) extends ModelTable[Webhook](tag, "project_webhooks" 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)).<>( + (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 17e0a2922..6ec6f0385 100644 --- a/models/src/main/scala/ore/models/Job.scala +++ b/models/src/main/scala/ore/models/Job.scala @@ -183,6 +183,8 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { info: JobInfo, projectOwner: String, projectSlug: String, + webhookId: DbRef[Webhook], + webhookSecret: String, callbackUrl: String, webhookType: Webhook.WebhookEventType, data: Json @@ -193,6 +195,8 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { 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 @@ -205,9 +209,22 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { 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, webhookType, data) + ): PostWebhookResponse = + PostWebhookResponse( + JobInfo.newJob(this), + projectOwner, + projectSlug, + webhookId, + webhookSecret, + callbackUrl, + webhookType, + data + ) override type CaseClass = PostWebhookResponse @@ -215,7 +232,12 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { for { projectOwner <- properties.get("project_owner").toRight("No project owner found") projectSlug <- properties.get("project_slug").toRight("No project slug found") - callbackUrl <- properties.get("webhook_callback").toRight("No callback url 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") @@ -224,7 +246,16 @@ object Job extends DefaultModelCompanion[Job, JobTable](TableQuery[JobTable]) { .get("webhook_data") .toRight("No webhook data found") .flatMap(s => io.circe.parser.parse(s).leftMap(_.show)) - } yield PostWebhookResponse(info, projectOwner, projectSlug, callbackUrl, webhookType, data) + } 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 index 3e7c67f72..a13692434 100644 --- a/models/src/main/scala/ore/models/project/Webhook.scala +++ b/models/src/main/scala/ore/models/project/Webhook.scala @@ -15,7 +15,9 @@ case class Webhook( name: String, callbackUrl: String, discordFormatted: Boolean, - events: List[Webhook.WebhookEventType] + events: List[Webhook.WebhookEventType], + secret: String, + lastError: Option[String] ) object Webhook extends DefaultModelCompanion[Webhook, WebhookTable](TableQuery[WebhookTable]) { @@ -25,6 +27,7 @@ object Webhook extends DefaultModelCompanion[Webhook, WebhookTable](TableQuery[W 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") 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 index 946488a7c..3a15f6da3 100644 --- a/ore/conf/evolutions/default/143_add_callbacks.sql +++ b/ore/conf/evolutions/default/143_add_callbacks.sql @@ -5,11 +5,13 @@ 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, + public_id UUID NOT NULL UNIQUE, name TEXT NOT NULL, callback_url TEXT NOT NULL, discord_formatted BOOLEAN NOT NULL, - events TEXT[] NOT NULL + events TEXT[] NOT NULL, + secret TEXT NOT NULL, + last_error TEXT ); # --- !Downs 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/orePlayCommon/app/db/impl/query/SharedQueries.scala b/orePlayCommon/app/db/impl/query/SharedQueries.scala index ba662f042..4dc0a7edf 100644 --- a/orePlayCommon/app/db/impl/query/SharedQueries.scala +++ b/orePlayCommon/app/db/impl/query/SharedQueries.scala @@ -33,10 +33,13 @@ object SharedQueries extends DoobieOreProtocol { | 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);""".stripMargin.update + | 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 index b67eb1acc..ca66e0921 100644 --- a/orePlayCommon/app/ore/WebhookJobAdder.scala +++ b/orePlayCommon/app/ore/WebhookJobAdder.scala @@ -4,7 +4,8 @@ import _root_.db.impl.query.SharedQueries import ore.db.{DbRef, ModelService} import ore.models.project.{Project, Webhook} -import ackcord.requests.CreateMessageData +import ackcord.data.OutgoingEmbed +import ackcord.requests.ExecuteWebhookData import io.circe.Encoder import io.circe.syntax._ import zio.UIO @@ -17,7 +18,7 @@ object WebhookJobAdder { projectSlug: String, webhookEvent: Webhook.WebhookEventType, data: A, - discordData: CreateMessageData //TODO: Replace with ExecuteWebhookData + discordData: OutgoingEmbed )( implicit service: ModelService[UIO] ): UIO[Unit] = @@ -30,7 +31,7 @@ object WebhookJobAdder { projectSlug, webhookEvent, data.asJson.noSpaces, - discordData.asJson.noSpaces + ExecuteWebhookData(embeds = Seq(discordData)).asJson.noSpaces ) .run ) diff --git a/project/dependencies.scala b/project/dependencies.scala index b2fd98d64..88b613df5 100644 --- a/project/dependencies.scala +++ b/project/dependencies.scala @@ -97,7 +97,7 @@ object Deps { ).map(flexmarkDep) lazy val squealCategoryMacro = "net.katsstuff" %% "squeal-category-macro" % Version.squeal - val ackcordRequests = "net.katsstuff" %% "ackcord-requests" % "0.17.1" + val ackcordRequests = "net.katsstuff" %% "ackcord-requests" % "0.18.0-SNAPSHOT" val pluginMeta = "org.spongepowered" % "plugin-meta" % "0.4.1"