From 1bed0a6534d21e67d737322e9a817d2ab1c807f2 Mon Sep 17 00:00:00 2001 From: zwiterrion Date: Wed, 4 Dec 2024 17:03:21 +0100 Subject: [PATCH] use cms directives inside emails --- daikoku/app/actions/actions.scala | 20 + daikoku/app/audit/audit.scala | 4 +- .../app/controllers/AdminApiController.scala | 1 + daikoku/app/controllers/ApiController.scala | 28 +- .../app/controllers/AssetsController.scala | 1 - .../app/controllers/CmsApiController.scala | 19 +- daikoku/app/controllers/HomeController.scala | 24 +- daikoku/app/controllers/LoginController.scala | 17 +- .../controllers/NotificationController.scala | 63 +- daikoku/app/controllers/TeamController.scala | 33 +- .../app/controllers/TenantController.scala | 16 +- daikoku/app/domain/CommonServices.scala | 2 +- daikoku/app/domain/SchemaDefinition.scala | 6 +- daikoku/app/domain/json.scala | 13 +- daikoku/app/domain/tenantEntities.scala | 1499 ---------------- daikoku/app/env/evolutions.scala | 12 +- daikoku/app/messages/Events.scala | 4 +- daikoku/app/services/CmsRenderer.scala | 1565 +++++++++++++++++ daikoku/app/storage/api.scala | 2 +- .../drivers/postgres/PostgresDataStore.scala | 1 + daikoku/app/utils/ApiService.scala | 48 +- daikoku/app/utils/Translator.scala | 65 +- daikoku/app/utils/emails.scala | 23 +- .../test/daikoku/AdminApiControllerSpec.scala | 1 + daikoku/test/daikoku/suites.scala | 1 + .../04-create-your-first-page copy.mdx | 4 +- .../06-style-your-apis-page.mdx | 4 +- .../09-create-react-component.mdx | 2 +- 28 files changed, 1786 insertions(+), 1692 deletions(-) create mode 100644 daikoku/app/services/CmsRenderer.scala diff --git a/daikoku/app/actions/actions.scala b/daikoku/app/actions/actions.scala index 5982d89e9..a4cc5cf5d 100644 --- a/daikoku/app/actions/actions.scala +++ b/daikoku/app/actions/actions.scala @@ -89,6 +89,26 @@ case class DaikokuTenantActionContext[A]( override def user: User = null } +case class DaikokuInternalActionMaybeWithoutUserContext[A]( + requestPath: String, + requestQueryString: Map[String, Seq[String]], + requestMethod: String, + requestHeaders: Map[String, String], + user: Option[User], + tenant: Tenant, + session: Option[UserSession], + impersonator: Option[User], + isTenantAdmin: Boolean, + apiCreationPermitted: Boolean = false, + ctx: TrieMap[String, String] = new TrieMap[String, String]() +) { + def setCtxValue(key: String, value: Any): Unit = { + if (value != null) { + ctx.put(key, value.toString) + } + } +} + case class DaikokuActionMaybeWithoutUserContext[A]( request: Request[A], user: Option[User], diff --git a/daikoku/app/audit/audit.scala b/daikoku/app/audit/audit.scala index 09868756f..fd53d258d 100644 --- a/daikoku/app/audit/audit.scala +++ b/daikoku/app/audit/audit.scala @@ -404,8 +404,8 @@ class AuditActor(implicit "mail.apikey.rotation.body", tenant, Map( - "apiName" -> api.name, - "planName" -> plan.customName.getOrElse(plan.typeName) + "apiName" -> JsString(api.name), + "planName" -> JsString(plan.customName.getOrElse(plan.typeName)) ) ) ) diff --git a/daikoku/app/controllers/AdminApiController.scala b/daikoku/app/controllers/AdminApiController.scala index 1cbb41249..f54257749 100644 --- a/daikoku/app/controllers/AdminApiController.scala +++ b/daikoku/app/controllers/AdminApiController.scala @@ -23,6 +23,7 @@ import play.api.http.HttpEntity import play.api.libs.json._ import play.api.libs.streams.Accumulator import play.api.mvc._ +import services.CmsPage import storage.drivers.postgres.PostgresDataStore import storage.{DataStore, Repo} diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 04ae52484..091e2ac3f 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -2955,10 +2955,10 @@ class ApiController( "mail.api.access.body", ctx.tenant, Map( - "user" -> ctx.user.name, - "apiName" -> api.name, - "teamName" -> team.name, - "link" -> env.getDaikokuUrl(ctx.tenant, "/notifications") + "user" -> JsString(ctx.user.name), + "apiName" -> JsString(api.name), + "teamName" -> JsString(team.name), + "link" -> JsString(env.getDaikokuUrl(ctx.tenant, "/notifications")) ) ) } yield { @@ -3715,13 +3715,13 @@ class ApiController( "mail.create.post.body", ctx.tenant, Map( - "user" -> ctx.user.name, - "apiName" -> api.get.humanReadableId, - "teamName" -> api.get.team.value, //not sure - "link" -> env.getDaikokuUrl( + "user" -> JsString(ctx.user.name), + "apiName" -> JsString(api.get.humanReadableId), + "teamName" -> JsString(api.get.team.value), //not sure + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, "/" + api.get.team.value + "/" + api.get.humanReadableId + "/" + api.get.currentVersion.value + "/news" - ) //same + )) //same ) ) } yield { @@ -4131,13 +4131,13 @@ class ApiController( "mail.new.issue.body", ctx.tenant, Map( - "user" -> ctx.user.name, - "apiName" -> api.name, - "teamName" -> api.team.value, // not sure if it's okay - "link" -> env.getDaikokuUrl( + "user" -> JsString(ctx.user.name), + "apiName" -> JsString(api.name), + "teamName" -> JsString(api.team.value), // not sure if it's okay + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, "/" + api.team.value + "/" + api.humanReadableId + "/" + api.currentVersion.value + "/issues" - ) //same + )) //same ) ) } yield { diff --git a/daikoku/app/controllers/AssetsController.scala b/daikoku/app/controllers/AssetsController.scala index f9db3c490..077b3373b 100644 --- a/daikoku/app/controllers/AssetsController.scala +++ b/daikoku/app/controllers/AssetsController.scala @@ -317,7 +317,6 @@ class TeamAssetsController( ) } case None => - println("HEHE") NotFound(Json.obj("error" -> "Asset not found!")) } } diff --git a/daikoku/app/controllers/CmsApiController.scala b/daikoku/app/controllers/CmsApiController.scala index 065a5a1af..7c4b6eb14 100644 --- a/daikoku/app/controllers/CmsApiController.scala +++ b/daikoku/app/controllers/CmsApiController.scala @@ -3,22 +3,9 @@ package fr.maif.otoroshi.daikoku.ctrls import cats.data.EitherT import cats.implicits.toBifunctorOps import controllers.{AppError, Assets} -import fr.maif.otoroshi.daikoku.actions.{ - ApiActionContext, - CmsApiAction, - DaikokuActionMaybeWithoutUser -} -import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent -import fr.maif.otoroshi.daikoku.ctrls.authorizations.async.TenantAdminOnly +import fr.maif.otoroshi.daikoku.actions.{ApiActionContext, CmsApiAction, DaikokuActionMaybeWithoutUser} import fr.maif.otoroshi.daikoku.domain.json.{CmsFileFormat, CmsPageFormat} -import fr.maif.otoroshi.daikoku.domain.{ - CmsPage, - CmsPageId, - Tenant, - TenantMode, - User, - UserSession -} +import fr.maif.otoroshi.daikoku.domain.{CmsPageId, Tenant, TenantMode, User} import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.login.AuthProvider.{OAuth2, Otoroshi} @@ -34,6 +21,7 @@ import org.apache.pekko.util.ByteString import play.api.Logger import play.api.libs.json._ import play.api.mvc._ +import services.CmsPage import storage.{DataStore, Repo} import scala.collection.concurrent.TrieMap @@ -89,7 +77,6 @@ class CmsApiController( case raw: AnyContentAsRaw => Source.single(raw.raw.asBytes().getOrElse(ByteString.empty)) case e => - println(e) throw new IllegalArgumentException("Request body is not raw data") } } diff --git a/daikoku/app/controllers/HomeController.scala b/daikoku/app/controllers/HomeController.scala index 6178d11e6..5aef4e7ac 100644 --- a/daikoku/app/controllers/HomeController.scala +++ b/daikoku/app/controllers/HomeController.scala @@ -1,5 +1,6 @@ package fr.maif.otoroshi.daikoku.ctrls +import cats.implicits.catsSyntaxOptionId import controllers.Assets import daikoku.BuildInfo import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionMaybeWithGuest, DaikokuActionMaybeWithoutUser, DaikokuActionMaybeWithoutUserContext} @@ -12,8 +13,10 @@ import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.utils.Errors import org.apache.pekko.http.scaladsl.util.FastFuture import play.api.i18n.{I18nSupport, MessagesApi} +import play.api.libs import play.api.libs.json._ import play.api.mvc._ +import services.{CmsPage, CmsRequestRendering} import scala.collection.mutable import scala.concurrent.{ExecutionContext, Future} @@ -186,8 +189,7 @@ class HomeController( } } ) - println("matching routes") - matchingRoutes.foreach(p => println(p._1.mkString(", "))) + if (matchingRoutes.nonEmpty) matchingRoutes.map(p => (p._1.tail, p._2)) else { @@ -323,7 +325,14 @@ class HomeController( .findById(p.notFoundCmsPage.get) .flatMap { case Some(page) => - page.render(ctx, req = None).map(res => Ok(res._1).as(res._2)) + page.render(page.maybeWithoutUserToUserContext(ctx.tenant, + ctx.request.asInstanceOf[Request[libs.json.JsValue]].some, + ctx.user, + ctx.session, + ctx.impersonator, + ctx.isTenantAdmin, + ctx.apiCreationPermitted, + ctx.ctx), req = None).map(res => Ok(res._1).as(res._2)) case _ => Errors.craftResponseResult( "Page not found !", @@ -350,7 +359,14 @@ class HomeController( req: Option[CmsRequestRendering] = None, fields: Map[String, JsValue] = Map.empty[String, JsValue] ) = { - r.render(ctx, None, req = req, jsonToCombine = fields) + r.render(r.maybeWithoutUserToUserContext(ctx.tenant, + ctx.request.asInstanceOf[Request[libs.json.JsValue]].some, + ctx.user, + ctx.session, + ctx.impersonator, + ctx.isTenantAdmin, + ctx.apiCreationPermitted, + ctx.ctx), None, req = req, fields = fields, jsonToCombine = fields) .map(res => { Ok(res._1).as(res._2) }) diff --git a/daikoku/app/controllers/LoginController.scala b/daikoku/app/controllers/LoginController.scala index 3012e4058..5ea302faf 100644 --- a/daikoku/app/controllers/LoginController.scala +++ b/daikoku/app/controllers/LoginController.scala @@ -3,12 +3,7 @@ package fr.maif.otoroshi.daikoku.ctrls import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator import com.google.common.base.Charsets import controllers.Assets -import fr.maif.otoroshi.daikoku.actions.{ - DaikokuAction, - DaikokuActionMaybeWithoutUser, - DaikokuTenantAction, - DaikokuTenantActionContext -} +import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionMaybeWithoutUser, DaikokuTenantAction, DaikokuTenantActionContext} import fr.maif.otoroshi.daikoku.audit.{AuditTrailEvent, AuthorizationLevel} import fr.maif.otoroshi.daikoku.domain.TeamPermission.Administrator import fr.maif.otoroshi.daikoku.domain._ @@ -21,7 +16,7 @@ import org.apache.commons.codec.binary.Base32 import org.apache.pekko.http.scaladsl.util.FastFuture import org.joda.time.DateTime import org.mindrot.jbcrypt.BCrypt -import play.api.libs.json.{JsObject, JsValue, Json} +import play.api.libs.json.{JsObject, JsString, JsValue, Json} import play.api.mvc._ import java.net.URLEncoder @@ -512,17 +507,17 @@ class LoginController( title <- translator.translate( "mail.new.user.title", ctx.tenant, - Map("tenant" -> ctx.tenant.name) + Map("tenant" -> JsString(ctx.tenant.name)) ) body <- translator.translate( "mail.new.user.body", ctx.tenant, Map( - "tenant" -> ctx.tenant.name, - "link" -> env.getDaikokuUrl( + "tenant" -> JsString(ctx.tenant.name), + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, s"/account/validate?id=$randomId" - ) + )) ) ) } yield { diff --git a/daikoku/app/controllers/NotificationController.scala b/daikoku/app/controllers/NotificationController.scala index 03e889791..6519bfa7c 100644 --- a/daikoku/app/controllers/NotificationController.scala +++ b/daikoku/app/controllers/NotificationController.scala @@ -5,11 +5,7 @@ import cats.data.EitherT import cats.implicits.catsSyntaxOptionId import controllers.AppError import controllers.AppError._ -import fr.maif.otoroshi.daikoku.actions.{ - DaikokuAction, - DaikokuActionContext, - DaikokuActionMaybeWithGuest -} +import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionContext, DaikokuActionMaybeWithGuest} import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent import fr.maif.otoroshi.daikoku.ctrls.authorizations.async._ import fr.maif.otoroshi.daikoku.domain.NotificationAction._ @@ -18,13 +14,8 @@ import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.utils.{ApiService, Translator} import play.api.i18n.I18nSupport -import play.api.libs.json.{JsArray, JsObject, JsValue, Json} -import play.api.mvc.{ - AbstractController, - AnyContent, - ControllerComponents, - Result -} +import play.api.libs.json.{JsArray, JsObject, JsString, JsValue, Json} +import play.api.mvc.{AbstractController, AnyContent, ControllerComponents, Result} import scala.concurrent.{ExecutionContext, Future} @@ -404,14 +395,14 @@ class NotificationController( translator.translate( "mail.api.access.rejection.body", ctx.tenant, - Map("apiName" -> unrecognizedApi) + Map("apiName" -> JsString(unrecognizedApi)) ) } case Some(api) => translator.translate( "mail.api.access.rejection.body", ctx.tenant, - Map("apiName" -> api.name) + Map("apiName" -> JsString(api.name)) ) } case TeamAccess(team) => @@ -426,14 +417,14 @@ class NotificationController( translator.translate( "mail.team.access.rejection.body", ctx.tenant, - Map("teamName" -> unrecognizedApi) + Map("teamName" -> JsString(unrecognizedApi)) ) } case Some(team) => translator.translate( "mail.team.access.rejection.body", ctx.tenant, - Map("teamName" -> team.name) + Map("teamName" -> JsString(team.name)) ) } case TeamInvitation(team, user) => @@ -452,8 +443,8 @@ class NotificationController( "mail.user.invitation.rejection.body", ctx.tenant, Map( - "user" -> unrecognizedUser, - "teamName" -> unrecognizedTeam + "user" -> JsString(unrecognizedUser), + "teamName" -> JsString(unrecognizedTeam) ) ) }).flatten @@ -469,8 +460,8 @@ class NotificationController( "mail.user.invitation.rejection.body", ctx.tenant, Map( - "teamName" -> team.name, - "user" -> unrecognizedUser + "teamName" -> JsString(team.name), + "user" -> JsString(unrecognizedUser) ) ) } @@ -479,7 +470,7 @@ class NotificationController( translator.translate( "mail.user.invitation.rejection.body", ctx.tenant, - Map("user" -> user.name, "teamName" -> team.name) + Map("user" -> JsString(user.name), "teamName" -> JsString(team.name)) ) } } @@ -524,10 +515,10 @@ class NotificationController( "mail.api.subscription.rejection.body", ctx.tenant, Map( - "user" -> maybeUser.getOrElse(unknownUser), - "team" -> team.map(_.name).getOrElse(unrecognizedTeam), - "apiName" -> maybeApi.map(_.name).getOrElse(unrecognizedApi), - "message" -> maybeMessage.getOrElse("") + "user" -> JsString(maybeUser.getOrElse(unknownUser)), + "team" -> JsString(team.map(_.name).getOrElse(unrecognizedTeam)), + "apiName" -> JsString(maybeApi.map(_.name).getOrElse(unrecognizedApi)), + "message" -> JsString(maybeMessage.getOrElse("")) ) ) } yield body @@ -550,8 +541,8 @@ class NotificationController( "mail.api.transfer.ownership.rejection.body", ctx.tenant, Map( - "apiName" -> api.map(_.name).getOrElse(unrecognizedApi), - "teamName" -> team.map(_.name).getOrElse(unrecognizedTeam) + "apiName" -> JsString(api.map(_.name).getOrElse(unrecognizedApi)), + "teamName" -> JsString(team.map(_.name).getOrElse(unrecognizedTeam)) ) ) } @@ -613,8 +604,8 @@ class NotificationController( "mail.user.invitation.rejection.body", ctx.tenant, Map( - "teamName" -> unrecognizedTeam, - "user" -> unrecognizedUser + "teamName" -> JsString(unrecognizedTeam), + "user" -> JsString(unrecognizedUser) ) ) }).flatten @@ -631,8 +622,8 @@ class NotificationController( "mail.user.invitation.rejection.body", ctx.tenant, Map( - "teamName" -> team.name, - "user" -> unrecognizedUser + "teamName" -> JsString(team.name), + "user" -> JsString(unrecognizedUser) ) ) } @@ -640,7 +631,7 @@ class NotificationController( translator.translate( "mail.user.invitation.rejection.body", ctx.tenant, - Map("user" -> user.name, "teamName" -> team.name) + Map("user" -> JsString(user.name), "teamName" -> JsString(team.name)) ) } } @@ -757,7 +748,7 @@ class NotificationController( body <- translator.translate( "mail.api.access.acceptation.body", tenant, - Map("apiName" -> api.name, "user" -> sender.name) + Map("apiName" -> JsString(api.name), "user" -> JsString(sender.name)) ) } yield { tenant.mailer.send(title, Seq(admin.email), body, tenant) @@ -791,7 +782,7 @@ class NotificationController( body <- translator.translate( "mail.team.access.acceptation.body", tenant, - Map("teamName" -> team.name) + Map("teamName" -> JsString(team.name)) ) _ <- tenant.mailer.send(title, Seq(sender.email), body, tenant) } yield Right(()) @@ -836,8 +827,8 @@ class NotificationController( "mail.user.invitation.acceptation.body", tenant, Map( - "user" -> invitedUser.name, - "teamName" -> team.name + "user" -> JsString(invitedUser.name), + "teamName" -> JsString(team.name) ) ) ) diff --git a/daikoku/app/controllers/TeamController.scala b/daikoku/app/controllers/TeamController.scala index 0d6f8e739..f5f01c725 100644 --- a/daikoku/app/controllers/TeamController.scala +++ b/daikoku/app/controllers/TeamController.scala @@ -193,11 +193,12 @@ class TeamController( "mail.create.team.token.body", ctx.tenant, Map( - "team" -> team.name, - "link" -> env.getDaikokuUrl( + "objTeam" -> team.asJson, + "team" -> JsString(team.name), + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, s"/api/teams/${team.humanReadableId}/_verify?token=$cipheredValidationToken" - ) + )) ) ) ) @@ -344,11 +345,11 @@ class TeamController( "mail.create.team.token.body", ctx.tenant, Map( - "team" -> team.name, - "link" -> env.getDaikokuUrl( + "team" -> JsString(team.name), + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, s"/api/teams/${team.humanReadableId}/_verify?token=$cipheredValidationToken" - ) + )) ) ) _ <- @@ -435,11 +436,11 @@ class TeamController( "mail.create.team.token.body", ctx.tenant, Map( - "team" -> teamToSave.name, - "link" -> env.getDaikokuUrl( + "team" -> JsString(teamToSave.name), + "link" -> JsString(env.getDaikokuUrl( ctx.tenant, s"/api/teams/${teamToSave.humanReadableId}/_verify?token=$cipheredValidationToken" - ) + )) ) ) _ <- ctx.tenant.mailer.send( @@ -563,10 +564,10 @@ class TeamController( "mail.team.access.body", ctx.tenant, Map( - "user" -> ctx.user.name, - "teamName" -> team.name, - "link" -> env - .getDaikokuUrl(ctx.tenant, "/notifications") + "user" -> JsString(ctx.user.name), + "teamName" -> JsString(team.name), + "link" -> JsString(env + .getDaikokuUrl(ctx.tenant, "/notifications")) ) ) } yield { @@ -743,9 +744,9 @@ class TeamController( "mail.team.invitation.body", ctx.tenant, Map( - "user" -> ctx.user.name, - "teamName" -> team.name, - "link" -> env.getDaikokuUrl(ctx.tenant, "/notifications") + "user" -> JsString(ctx.user.name), + "teamName" -> JsString(team.name), + "link" -> JsString(env.getDaikokuUrl(ctx.tenant, "/notifications")) ) ) } yield { diff --git a/daikoku/app/controllers/TenantController.scala b/daikoku/app/controllers/TenantController.scala index 6db878d27..4c436dfa2 100644 --- a/daikoku/app/controllers/TenantController.scala +++ b/daikoku/app/controllers/TenantController.scala @@ -572,20 +572,20 @@ class TenantController( "mail.contact.sender", ctx.tenant, Map( - "user" -> name, - "email" -> email, - "subject" -> subject, - "body" -> sanitizeBody + "user" -> JsString(name), + "email" -> JsString(email), + "subject" -> JsString(subject), + "body" -> JsString(sanitizeBody) ) ) mailToContact <- translator.translate( "mail.contact.contact", ctx.tenant, Map( - "user" -> name, - "email" -> email, - "subject" -> subject, - "body" -> sanitizeBody + "user" -> JsString(name), + "email" -> JsString(email), + "subject" -> JsString(subject), + "body" -> JsString(sanitizeBody) ) ) _ <- ctx.tenant.mailer.send( diff --git a/daikoku/app/domain/CommonServices.scala b/daikoku/app/domain/CommonServices.scala index 063b06c12..2b122d6c8 100644 --- a/daikoku/app/domain/CommonServices.scala +++ b/daikoku/app/domain/CommonServices.scala @@ -313,7 +313,7 @@ object CommonServices { } } - def getVisibleApis[A]( + def getVisibleApis( teamId: Option[String] = None, research: String, selectedTeam: Option[String] = None, diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 09dae2775..45365904d 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -6,10 +6,7 @@ import controllers.AppError import fr.maif.otoroshi.daikoku.actions.DaikokuActionContext import fr.maif.otoroshi.daikoku.audit._ import fr.maif.otoroshi.daikoku.audit.config._ -import fr.maif.otoroshi.daikoku.ctrls.authorizations.async.{ - _TeamMemberOnly, - _TenantAdminAccessTenant -} +import fr.maif.otoroshi.daikoku.ctrls.authorizations.async.{_TeamMemberOnly, _TenantAdminAccessTenant} import fr.maif.otoroshi.daikoku.domain.NotificationAction._ import fr.maif.otoroshi.daikoku.domain.json.{TenantIdFormat, UserIdFormat} import fr.maif.otoroshi.daikoku.env.Env @@ -22,6 +19,7 @@ import sangria.execution.deferred.{DeferredResolver, Fetcher, HasId} import sangria.macros.derive._ import sangria.schema.{Context, _} import sangria.validation.ValueCoercionViolation +import services.CmsPage import storage._ import java.util.concurrent.TimeUnit diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 70aec487f..fa24a19bd 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -6,21 +6,12 @@ import fr.maif.otoroshi.daikoku.audit.KafkaConfig import fr.maif.otoroshi.daikoku.audit.config.{ElasticAnalyticsConfig, Webhook} import fr.maif.otoroshi.daikoku.domain.ApiVisibility._ import fr.maif.otoroshi.daikoku.domain.NotificationAction._ -import fr.maif.otoroshi.daikoku.domain.NotificationStatus.{ - Accepted, - Pending, - Rejected -} +import fr.maif.otoroshi.daikoku.domain.NotificationStatus.{Accepted, Pending, Rejected} import fr.maif.otoroshi.daikoku.domain.TeamPermission._ import fr.maif.otoroshi.daikoku.domain.TeamType.{Organization, Personal} import fr.maif.otoroshi.daikoku.domain.ThirdPartyPaymentSettings.StripeSettings import fr.maif.otoroshi.daikoku.domain.ThirdPartySubscriptionInformations.StripeSubscriptionInformations import fr.maif.otoroshi.daikoku.domain.UsagePlan._ -import fr.maif.otoroshi.daikoku.domain.json.{ - ApiIdFormat, - TeamIdFormat, - TenantIdFormat -} import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.login.AuthProvider @@ -28,8 +19,8 @@ import fr.maif.otoroshi.daikoku.utils.StringImplicits._ import fr.maif.otoroshi.daikoku.utils._ import org.joda.time.DateTime import play.api.libs.json._ +import services.{CmsFile, CmsPage, CmsRequestRendering} -import java.util import java.util.concurrent.TimeUnit import scala.concurrent.duration.FiniteDuration import scala.util.{Failure, Success, Try} diff --git a/daikoku/app/domain/tenantEntities.scala b/daikoku/app/domain/tenantEntities.scala index 9568a5b58..b64b00461 100644 --- a/daikoku/app/domain/tenantEntities.scala +++ b/daikoku/app/domain/tenantEntities.scala @@ -615,1506 +615,7 @@ case class ApiKeyRestrictions( def asJson: JsValue = json.ApiKeyRestrictionsFormat.writes(this) } -// ########### CMS ############# - -object CmsPage { - val pageRenderingEc = ExecutionContext.fromExecutor( - Executors.newWorkStealingPool(Runtime.getRuntime.availableProcessors() + 1) - ) -} - -case class CmsFile( - name: String, - content: String, - metadata: Map[String, JsValue] = Map.empty, - daikokuData: Option[Map[String, String]] = None -) { - def path(): String = metadata.getOrElse("_path", JsString("")).as[String] - def contentType(): String = - metadata.getOrElse("_content_type", JsString("")).as[String] - - def authenticated(): Boolean = - Json - .parse(metadata.getOrElse("_authenticated", JsString("false")).as[String]) - .as[Boolean] - def visible(): Boolean = - Json - .parse(metadata.getOrElse("_visible", JsString("true")).as[String]) - .as[Boolean] - def exact(): Boolean = - Json - .parse(metadata.getOrElse("_exact", JsString("false")).as[String]) - .as[Boolean] - - def id() = { - val defaultId = path().replaceAll("/", "-") - daikokuData - .map(data => data.getOrElse("id", defaultId)) - .getOrElse(defaultId) - } - - def toCmsPage(tenantId: TenantId): CmsPage = { - CmsPage( - id = CmsPageId(id()), - tenant = tenantId, - visible = visible(), - authenticated = authenticated(), - exact = exact(), - name = name, - forwardRef = None, - tags = List.empty, - metadata = metadata.map { - case (key, value) => (key, value.toString.replaceAll("\"", "")) - }, - contentType = contentType(), - body = content, - path = Some(path()) - ) - } -} - -case class CmsRequestRendering(content: Seq[CmsFile], current_page: String, fields: Map[String, JsValue]) - case class Asset(id: AssetId, tenant: TenantId, slug: String) extends CanJson[Asset] { override def asJson: JsValue = json.AssetFormat.writes(this) } - -case class CmsPage( - id: CmsPageId, - tenant: TenantId, - deleted: Boolean = false, - visible: Boolean, - authenticated: Boolean, - name: String, - picture: Option[String] = None, - forwardRef: Option[CmsPageId], - tags: List[String], - metadata: Map[String, String], - contentType: String, - body: String, - path: Option[String] = None, - exact: Boolean = false, - lastPublishedDate: Option[DateTime] = None -) extends CanJson[CmsPage] { - override def asJson: JsValue = json.CmsPageFormat.writes(this) - - def enrichHandlebarsWithPublicUserEntity[A]( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - env: Env, - ec: ExecutionContext, - messagesApi: MessagesApi - ): Handlebars = { - handlebars.registerHelper( - s"daikoku-user", - (id: String, options: Options) => { - val userId = renderString(ctx, parentId, id, fields, jsonToCombine, req) - val optUser = - Await.result(env.dataStore.userRepo.findById(userId), 10.seconds) - - optUser match { - case Some(user) => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map( - "user" -> Json.obj( - "_id" -> user.id.value, - "name" -> user.name, - "email" -> user.email, - "picture" -> user.picture - ) - ), - req - ) - case None => AppError.render(AppError.UserNotFound()) - } - } - ) - } - - def enrichHandlebarsWithOwnedApis[A]( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - env: Env, - ec: ExecutionContext, - messagesApi: MessagesApi - ): Handlebars = { - val ctxUserContext = maybeWithoutUserToUserContext(ctx) - val name = "owned-api" - - handlebars.registerHelper( - s"daikoku-${name}s", - (_: CmsPage, options: Options) => { - val visibility = - options.hash.getOrDefault("visibility", "All").asInstanceOf[String] - Await.result( - CommonServices.getVisibleApis( - research = "", - limit = Int.MaxValue, - offset = 0 - )(ctxUserContext, env, ec), - 10.seconds - ) match { - case Right(ApiWithCount(apis, producers, _)) => - apis - .filter(api => - if (visibility == "All") true - else api.api.visibility.name == visibility - ) - .map(api => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map("api" -> api.api.asJson), - req - ) - ) - .mkString("\n") - case Left(error) => AppError.render(error) - } - } - ) - handlebars.registerHelper( - s"daikoku-$name", - (id: String, options: Options) => { - val renderedParameter = - renderString(ctx, parentId, id, fields, jsonToCombine, req) - val version = - options.hash.getOrDefault("version", "1.0.0").asInstanceOf[String] - val optApi = Await.result( - env.dataStore.apiRepo - .findByVersion(ctx.tenant, renderedParameter, version), - 10.seconds - ) - - optApi match { - case Some(api) => - Await.result( - CommonServices - .apiOfTeam(api.team.value, api.id.value, version)( - ctx.asInstanceOf[DaikokuActionContext[Any]], - env, - ec - ) - .map { - case Right(api) => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = - jsonToCombine ++ Map("api" -> api.api.asJson), - req = req - ) - case Left(error) => AppError.render(error) - }, - 10.seconds - ) - case None => AppError.render(AppError.ApiNotFound) - } - } - ) - handlebars.registerHelper( - s"daikoku-json-$name", - (id: String, options: Options) => { - val renderedParameter = - renderString(ctx, parentId, id, fields, jsonToCombine, req) - val version = - options.hash.getOrDefault("version", "1.0.0").asInstanceOf[String] - val optApi = Await.result( - env.dataStore.apiRepo - .findByVersion(ctx.tenant, renderedParameter, version), - 10.seconds - ) - - optApi match { - case Some(api) => - Await.result( - CommonServices - .apiOfTeam(api.team.value, api.id.value, version)( - ctx.asInstanceOf[DaikokuActionContext[Any]], - env, - ec - ) - .map { - case Right(api) => api.api.asJson - case Left(error) => AppError.render(error) - }, - 10.seconds - ) - case None => toJson(AppError.ApiNotFound) - } - } - ) - handlebars.registerHelper( - s"daikoku-json-${name}s", - (_: CmsPage, _: Options) => - Await.result( - CommonServices - .getVisibleApis(research = "", limit = Int.MaxValue, offset = 0)( - ctxUserContext, - env, - ec - ) - .map { - case Right(ApiWithCount(apis, producers, _)) => - JsArray(apis.map(_.api.asJson)) - case Left(error) => toJson(error) - }, - 10.seconds - ) - ) - } - - private def maybeWithoutUserToUserContext( - ctx: DaikokuActionMaybeWithoutUserContext[_] - ): DaikokuActionContext[JsValue] = - DaikokuActionContext( - request = ctx.request.asInstanceOf[Request[JsValue]], - user = ctx.user.getOrElse( - User( - UserId("Unauthenticated user"), - tenants = Set.empty, - origins = Set.empty, - name = "Unauthenticated user", - email = "unauthenticated@foo.bar", - personalToken = None, - lastTenant = None, - defaultLanguage = None - ) - ), - tenant = ctx.tenant, - session = ctx.session.orNull, - impersonator = ctx.impersonator, - isTenantAdmin = ctx.isTenantAdmin, - apiCreationPermitted = ctx.apiCreationPermitted, - ctx = ctx.ctx - ) - - def enrichHandlebarsWithOwnedTeams[A]( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - env: Env, - ec: ExecutionContext, - messagesApi: MessagesApi - ): Handlebars = { - val ctxUserContext = maybeWithoutUserToUserContext(ctx) - - handlebars.registerHelper( - s"daikoku-owned-teams", - (_: CmsPage, options: Options) => { - Await.result( - CommonServices.myTeams()(ctxUserContext, env, ec), - 10.seconds - ) match { - case Right(teams) => - teams - .map(team => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map("team" -> team.asJson), - req = req - )(env, ec, messagesApi) - ) - .mkString("\n") - case Left(error) => AppError.render(error) - } - } - ) - handlebars.registerHelper( - s"daikoku-owned-team", - (_: String, options: Options) => { - Await.result( - _UberPublicUserAccess( - AuditTrailEvent( - s"@{user.name} has accessed its first team on @{tenant.name}" - ) - )(ctxUserContext) { - env.dataStore.teamRepo - .forTenant(ctx.tenant.id) - .findOne( - Json.obj( - "_deleted" -> false, - "type" -> TeamType.Personal.name, - "users.userId" -> ctx.user.get.id.value - ) - ) - .map { - case None => AppError.TeamNotFound - case Some(team) if team.includeUser(ctx.user.get.id) => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = - jsonToCombine ++ Map("team" -> team.asSimpleJson), - req = req - ) - case _ => AppError.TeamUnauthorized - } - }, - 10.seconds - ) match { - case Right(e) => e - case Left(error) => toJson(error) - } - } - ) - handlebars.registerHelper( - s"daikoku-json-owned-team", - (id: String, options: Options) => { - val teamId = renderString(ctx, parentId, id, fields, jsonToCombine, req) - - Await.result( - _TeamMemberOnly( - teamId, - AuditTrailEvent( - "@{user.name} has accessed on of his team @{team.name} - @{team.id}" - ) - )(ctxUserContext) { team => - ctx.setCtxValue("team.name", team.name) - ctx.setCtxValue("team.id", team.id) - - FastFuture.successful(Right(team.toUiPayload())) - }, - 10.seconds - ) match { - case Right(jsonTeam) => jsonTeam - case Left(error) => toJson(error) - } - } - ) - handlebars.registerHelper( - s"daikoku-json-owned-teams", - (_: CmsPage, _: Options) => - Await.result( - CommonServices.myTeams()(ctxUserContext, env, ec).map { - case Right(teams) => JsArray(teams.map(_.asJson)) - case Left(error) => toJson(error) - }, - 10.seconds - ) - ) - } - - def enrichHandlebarsWithEntity[A]( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - name: String, - getRepo: Env => TenantCapableRepo[A, _], - stringify: A => JsValue, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - ec: ExecutionContext, - messagesApi: MessagesApi, - env: Env - ): Handlebars = { - val repo: TenantCapableRepo[A, _] = getRepo(env) - handlebars.registerHelper( - s"daikoku-${name}s", - (_: CmsPage, options: Options) => { - val apis = Await - .result(repo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) - apis - .map(api => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map(name -> stringify(api)), - req - ) - ) - .mkString("\n") - } - ) - handlebars.registerHelper( - s"daikoku-$name", - (id: String, options: Options) => { - Await - .result( - repo - .forTenant(ctx.tenant) - .findByIdOrHrIdNotDeleted( - renderString(ctx, parentId, id, fields, jsonToCombine, req) - ), - 10.seconds - ) - .map(api => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map(name -> stringify(api)), - req - ) - ) - .getOrElse(s"$name not found") - } - ) - handlebars.registerHelper( - s"daikoku-json-$name", - (id: String, _: Options) => - Await - .result(repo.forTenant(ctx.tenant).findByIdNotDeleted(id), 10.seconds) - .map(stringify) - .getOrElse("") - ) - handlebars.registerHelper( - s"daikoku-json-${name}s", - (_: CmsPage, _: Options) => - JsArray( - Await - .result(repo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) - .map(stringify) - ) - ) - } - - private def renderString( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - str: String, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = - Await - .result( - CmsPage( - id = CmsPageId(IdGenerator.token(32)), - tenant = ctx.tenant.id, - visible = true, - authenticated = false, - name = "#generated", - forwardRef = None, - tags = List(), - metadata = Map(), - contentType = "text/html", - body = str, - path = Some("/") - ).render( - ctx, - parentId, - fields = fields, - jsonToCombine = jsonToCombine, - req = req - ), - 10.seconds - ) - ._1 - - private def cmsFindByIdNotDeleted( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String, - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { - req match { - case Some(value) => - value.content - .find(p => cleanPath(p.path()) == cleanPath(id)) - .map(_.toCmsPage(ctx.tenant.id)) - case None => findCmsPageByTheId(ctx, id) - } - } - - private def cleanPath(path: String) = { - val out = path.replace("/_/", "/").replace(".html", "") - if (!path.startsWith("/")) - s"/$out" - else - out - } - - private def findCmsPageByTheId( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String - )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { - - Await.result( - env.dataStore.cmsRepo - .forTenant(ctx.tenant) - .findOne( - Json.obj( - "$or" -> Json.arr( - Json.obj("_id" -> cleanPath(id)), - Json.obj("_id" -> cleanPath(id).replace("/", "-")), - Json.obj("_id" -> cleanPath(id).replace("/", "-").substring(1)) - ) - ) - ), - 10.seconds - ) - } - - private def cmsFindById( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String, - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { - req match { - case Some(value) => - value.content - .find(_.path() == id) - .map(_.toCmsPage(ctx.tenant.id)) - case None => findCmsPageByTheId(ctx, id) - } - } - - private def cmsFindOneNotDeleted( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String, - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { - req match { - case Some(value) => - value.content - .find(p => cleanPath(p.path()) == cleanPath(id)) - .map(_.toCmsPage(ctx.tenant.id)) - case None => - Await.result( - env.dataStore.cmsRepo - .forTenant(ctx.tenant) - .findOneNotDeleted( - Json.obj( - "$or" -> Json.arr( - Json.obj("path" -> cleanPath(id)), - Json.obj("_id" -> cleanPath(id)), - Json.obj("_id" -> cleanPath(id).replace("/", "-")) - ) - ) - ), - 10.seconds - ) - } - } - - private def daikokuIncludeBlockHelper( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - id: String, - options: Options, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { - val outFields = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) - - cmsFindByIdNotDeleted(ctx, id, req) match { - case None => - cmsFindOneNotDeleted( - ctx, - renderString(ctx, parentId, id, outFields, jsonToCombine, req), - req - ) match { - case None => s"block '$id' not found" - case Some(page) => - Await.result( - page - .render( - ctx, - parentId, - fields = outFields, - jsonToCombine = jsonToCombine, - req = req - ) - .map(t => t._1), - 10.seconds - ) - } - case Some(page) => - Await.result( - page - .render( - ctx, - parentId, - fields = outFields, - jsonToCombine = jsonToCombine, - req - ) - .map(t => t._1), - 10.seconds - ) - } - } - - private def daikokuTemplateWrapper( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - id: String, - options: Options, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { - cmsFindByIdNotDeleted(ctx, id, req) match { - case None => "wrapper component not found" - case Some(page) => - val tmpFields = - getAttrs(ctx, parentId, options, fields, jsonToCombine, req) - val outFields = getAttrs( - ctx, - parentId, - options, - tmpFields ++ Map( - "children" -> Await - .result( - CmsPage( - id = CmsPageId(IdGenerator.token(32)), - tenant = ctx.tenant.id, - visible = true, - authenticated = false, - name = "#generated", - forwardRef = None, - tags = List(), - metadata = Map(), - contentType = "text/html", - body = options.fn.text(), - path = Some("/") - ).render( - ctx, - parentId, - fields = tmpFields, - jsonToCombine = jsonToCombine, - req = req - )(env, messagesApi), - 10.seconds - ) - ._1 - ), - jsonToCombine, - req = req - ) - Await.result( - page - .render( - ctx, - parentId, - fields = outFields, - jsonToCombine = jsonToCombine, - req = req - ) - .map(t => t._1), - 10.seconds - ) - } - } - - private def daikokuPathParam( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String, - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext) = { - val pages = req match { - case Some(value) => - value.content - .filter(p => p.path().nonEmpty) - .map(r => s"/_${r.path()}") - case None => - Await - .result( - env.dataStore.cmsRepo - .forTenant(ctx.tenant) - .findWithProjection(Json.obj(), Json.obj("path" -> true)), - 10.seconds - ) - .map(r => s"/_${(r \ "path").as[String]}") - } - - pages - .sortBy(_.length)(Ordering[Int].reverse) - .find(p => ctx.request.path.startsWith(p)) - .map(r => { - val params = ctx.request.path.split(r).filter(f => f.nonEmpty) - try { - if (params.length > 0) - params(0).split("/").filter(_.nonEmpty)(Integer.parseInt(id)) - else - s"path param $id not found" - } catch { - case _: Throwable => s"path param $id not found" - } - }) - .getOrElse(s"path param $id not found") - } - - private def daikokuPageUrl( - ctx: DaikokuActionMaybeWithoutUserContext[_], - id: String, - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { - cmsFindByIdNotDeleted(ctx, id, req) match { - case None => "#not-found" - case Some(page) => - var path = page.path.getOrElse("") - - if (!path.startsWith("/")) - path = s"/$path" - - - s"/_${path}" - } - } - - private def daikokuLinks( - ctx: DaikokuActionMaybeWithoutUserContext[_], - handlebars: Handlebars - ) = { - val links = Map( - "login" -> s"/auth/${ctx.tenant.authProvider.name}/login", - "logout" -> "/logout", - "language" -> ctx.user - .map(_.defaultLanguage) - .getOrElse(ctx.tenant.defaultLanguage.getOrElse("en")), - "signup" -> (if (ctx.tenant.authProvider.name == "Local") "/signup" - else s"/auth/${ctx.tenant.authProvider.name}/login"), - "backoffice" -> "/apis", - "notifications" -> "/notifications", - "home" -> "/" - ) - links.map { - case (name, link) => - handlebars.registerHelper( - s"daikoku-links-$name", - (_: Object, _: Options) => link - ) - } - } - - private def getAttrs( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - options: Options, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - env: Env, - ec: ExecutionContext, - messagesApi: MessagesApi - ): Map[String, Any] = { - import scala.jdk.CollectionConverters._ - fields ++ options.hash.asScala.map { - case (k, v) => - ( - k, - renderString( - ctx, - parentId, - v.toString, - fields, - jsonToCombine = jsonToCombine, - req = req - )(env, ec, messagesApi) - ) - }.toMap - } - - private def enrichHandlebarWithPlanEntity( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - name: String, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - ec: ExecutionContext, - messagesApi: MessagesApi, - env: Env - ): Handlebars = { - handlebars.registerHelper( - s" ${name}s-json", - (id: String, _: Options) => { - Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req).flatMap { - case Some(api) => - env.dataStore.usagePlanRepo.findByApi(tenant, api) - case None => FastFuture.successful(Seq.empty) - }, - 10.seconds - ) - .map(_.asJson) - } - ) - - handlebars.registerHelper( - s"daikoku-${name}s", - (id: String, options: Options) => { - Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req).flatMap { - case Some(api) => - env.dataStore.usagePlanRepo.findByApi(tenant, api) - case None => FastFuture.successful(Seq.empty) - }, - 10.seconds - ) - .map(p => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map(name -> p.asJson), - req = req - ) - ) - .mkString("\n") - } - ) - } - - private def getApi( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - id: String, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = - env.dataStore.apiRepo - .forTenant(tenant) - .findByIdOrHrId( - renderString( - ctx, - parentId, - id, - fields, - jsonToCombine = jsonToCombine, - req - )( - env, - ec, - messagesApi - ) - ) - - private def enrichHandlebarWithDocumentationEntity( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String], - handlebars: Handlebars, - name: String, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue], - req: Option[CmsRequestRendering] - )(implicit - ec: ExecutionContext, - messagesApi: MessagesApi, - env: Env - ): Handlebars = { - - def jsonToFields(pages: Seq[ApiDocumentationPage], options: Options) = - pages - .map(doc => - renderString( - ctx, - parentId, - options.fn.text(), - fields, - jsonToCombine = jsonToCombine ++ Map(name -> doc.asJson), - req = req - ) - ) - .mkString("\n") - - handlebars.registerHelper( - s"daikoku-$name", - (id: String, options: Options) => { - val pages = Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req) - .flatMap { - case Some(api) => - Future.sequence( - api.documentation - .docIds() - .map(pageId => - env.dataStore.apiDocumentationPageRepo - .forTenant(ctx.tenant) - .findById(pageId) - ) - ) - case _ => FastFuture.successful(Seq()) - }, - 10.seconds - ) - .flatten - - jsonToFields(pages, options) - } - ) - - handlebars.registerHelper( - s"daikoku-$name-json", - (id: String, options: Options) => { - Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req) - .flatMap { - case Some(api) => - Future.sequence( - api.documentation - .docIds() - .map(pageId => - env.dataStore.apiDocumentationPageRepo - .forTenant(ctx.tenant) - .findById(pageId) - ) - ) - case _ => FastFuture.successful(Seq()) - }, - 10.seconds - ) - .flatten - .map(_.asJson) - } - ) - - handlebars.registerHelper( - s"daikoku-$name-page", - (id: String, options: Options) => { - val attrs = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) - - val page: Int = - attrs.get("page").map(n => n.toString.toInt).getOrElse(0) - val pages = Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req) - .flatMap { - case Some(api) => - Future.sequence( - api.documentation - .docIds() - .slice(page, page + 1) - .map( - env.dataStore.apiDocumentationPageRepo - .forTenant(ctx.tenant) - .findById(_) - ) - ) - case _ => FastFuture.successful(Seq()) - }, - 10.seconds - ) - .flatten - - jsonToFields(pages, options) - } - ) - - handlebars.registerHelper( - s"daikoku-$name-page-id", - (id: String, options: Options) => { - val attrs = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) - val page = attrs.getOrElse("page", "") - Await - .result( - getApi(ctx, parentId, id, fields, jsonToCombine, req) - .flatMap { - case Some(api) => - api.documentation - .docIds() - .find(_ == page) - .map( - env.dataStore.apiDocumentationPageRepo - .forTenant(ctx.tenant) - .findById(_) - ) - .getOrElse(FastFuture.successful(None)) - case _ => FastFuture.successful(None) - }, - 10.seconds - ) - .map(doc => - renderString( - ctx, - parentId, - options.fn.text(), - fields = fields, - jsonToCombine = jsonToCombine ++ Map(name -> doc.asJson), - req = req - ) - ) - .getOrElse("") - } - ) - } - - def combineFieldsToContext( - context: Context.Builder, - fields: Map[String, Any], - jsonToCombine: Map[String, JsValue] - ): Context.Builder = - (fields ++ jsonToCombine.map { - case (key, value) => - ( - key, - value match { - case JsNull => null - case boolean: JsBoolean => boolean - case JsNumber(value) => value - case JsString(value) => value - case JsArray(value) => value - case o @ JsObject(underlying) => o - } - ) - }).foldLeft(context) { (acc, item) => - acc.combine(item._1, item._2) - } - - private def searchCmsFile( - req: CmsRequestRendering, - page: CmsPage - ): Option[CmsFile] = { - req.content.find(p => p.path() == page.path.getOrElse("")) - } - - def render( - ctx: DaikokuActionMaybeWithoutUserContext[_], - parentId: Option[String] = None, - fields: Map[String, Any] = Map.empty, - jsonToCombine: Map[String, JsValue] = Map.empty, - req: Option[CmsRequestRendering] - )(implicit env: Env, messagesApi: MessagesApi): Future[(String, String)] = { - implicit val ec: ExecutionContext = env.defaultExecutionContext - - val page = forwardRef match { - case Some(id) => cmsFindByIdNotDeleted(ctx, id.value, req).getOrElse(this) - case None => this - } - try { - import com.github.jknack.handlebars.EscapingStrategy - implicit val ec = CmsPage.pageRenderingEc - - if ( - page.authenticated && (ctx.user.isEmpty || ctx.user.exists(_.isGuest)) - ) - ctx.tenant.style.flatMap(_.authenticatedCmsPage) match { - case Some(value) => - cmsFindById(ctx, value, req) match { - case Some(value) => - value.render(ctx, parentId, fields, jsonToCombine, req) - case None => - FastFuture.successful(("Need to be logged", page.contentType)) - } - case None => - FastFuture.successful(("Need to be logged", page.contentType)) - } - else if (parentId.nonEmpty && page.id.value == parentId.get) - FastFuture.successful(("", page.contentType)) - else { - val template = req match { - case Some(value) if page.name != "#generated" => - searchCmsFile(value, page).map(_.content).getOrElse("") - case _ => page.body - } - - var contextBuilder = Context - .newBuilder(this) - .resolver(JsonNodeValueResolver.INSTANCE) - .combine("tenant", ctx.tenant.asJson) - .combine("is_admin", ctx.isTenantAdmin) - .combine("connected", ctx.user.map(!_.isGuest).getOrElse(false)) - .combine("user", ctx.user.map(u => u.asSimpleJson).getOrElse("")) - .combine("request", EntitiesToMap.request(ctx.request)) - .combine( - "daikoku-css", { - if (env.config.isDev) - s"http://localhost:3000/daikoku.css" - else if (env.config.isProd) - s"${env.getDaikokuUrl(ctx.tenant, "/assets/react-app/daikoku.min.css")}" - } - ) - - if (template.contains("{{apis}")) { - contextBuilder = contextBuilder.combine("apis", Json.stringify(JsArray(Await - .result(env.dataStore.apiRepo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) - .map(a => { - a.copy( - description = a.description.replaceAll("\n", "\\n"), - smallDescription = a.smallDescription.replaceAll("\n", "\\n")) - .asJson - })))) - } - - if (template.contains("{{teams}")) { - contextBuilder = contextBuilder.combine("teams", Json.stringify(JsArray(Await - .result(env.dataStore.teamRepo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) - .map(a => { - a.copy(description = a.description.replaceAll("\n", "\\n")).asJson - })))) - } - - if (template.contains("{{users}")) { - contextBuilder = contextBuilder.combine("users", Json.stringify(JsArray(Await - .result(env.dataStore.userRepo.findAllNotDeleted(), 10.seconds) - .map(_.toUiPayload())))) - } - - val context = combineFieldsToContext( - contextBuilder, - fields.map { - case (key, value) => - ( - key, - value match { - case JsString(value) => - value // remove quotes framing string - case value => value - } - ) - }, - jsonToCombine - ) - - req match { - case Some(value) if page.name != "#generated" => - searchCmsFile(value, page) - .foreach(_.metadata.foreach(p => { - context.combine( - p._1, - p._2 match { - case JsString(value) => - value // remove quotes framing string - case value => value - } - ) - })) - case _ => - } - - val handlebars = new Handlebars().`with`(new EscapingStrategy() { - override def escape(value: CharSequence): String = { - value.toString - } - }) - - handlebars.registerHelper( - "for", - (variable: String, options: Options) => { - val s = - renderString(ctx, parentId, variable, fields, jsonToCombine, req) - val field = options.hash.getOrDefault("field", "object").toString - - try { - Json - .parse(s) - .as[JsArray] - .value - .map(p => { - renderString( - ctx, - parentId, - options.fn.text(), - fields, - jsonToCombine ++ Map(field -> p), - req = req - ) - }) - .mkString("\n") - } catch { - case _: Throwable => Json.obj() - } - } - ) - handlebars.registerHelper( - "size", - (variable: String, _: Options) => { - val s = - renderString(ctx, parentId, variable, fields, jsonToCombine, req) - try { - String.valueOf(Json.parse(s).asInstanceOf[JsArray].value.length) - } catch { - case _: Throwable => "0" - } - } - ) - handlebars.registerHelper( - "ifeq", - (variable: String, options: Options) => { - if ( - renderString( - ctx, - parentId, - variable, - fields, - jsonToCombine, - req - ) == - renderString( - ctx, - parentId, - options.params(0).toString, - fields, - jsonToCombine, - req - ) - ) - options.fn.apply( - renderString( - ctx, - parentId, - options.fn.text(), - fields, - jsonToCombine, - req = req - ) - ) - else - "" - } - ) - handlebars.registerHelper( - "ifnoteq", - (variable: String, options: Options) => { - if ( - renderString( - ctx, - parentId, - variable, - fields, - jsonToCombine, - req - ) != - renderString( - ctx, - parentId, - options.params(0).toString, - fields, - jsonToCombine, - req - ) - ) - options.fn.apply( - renderString( - ctx, - parentId, - options.fn.text(), - fields, - jsonToCombine, - req - ) - ) - else - "" - } - ) - handlebars.registerHelper( - "getOrElse", - (variable: String, options: Options) => { - val str = - renderString(ctx, parentId, variable, fields, jsonToCombine, req) - if (str != "null" && str.nonEmpty) - str - else - renderString( - ctx, - parentId, - options.params(0).toString, - fields, - jsonToCombine, - req - ) - } - ) - handlebars.registerHelper( - "translate", - (variable: String, _: Options) => { - val str = - renderString(ctx, parentId, variable, fields, jsonToCombine, req) - Await.result( - env.translator.translate(str, ctx.tenant)( - messagesApi, - ctx.user - .map( - _.defaultLanguage - .getOrElse(ctx.tenant.defaultLanguage.getOrElse("en")) - ) - .getOrElse("en"), - env - ), - 10.seconds - ) - } - ) - handlebars.registerHelper( - "daikoku-asset-url", - (context: String, _: Options) => s"/tenant-assets/$context" - ) - handlebars.registerHelper( - "daikoku-page-url", - (id: String, _: Options) => daikokuPageUrl(ctx, id, req) - ) - handlebars.registerHelper( - "daikoku-generic-page-url", - (id: String, _: Options) => s"/cms/pages/$id" - ) - handlebars.registerHelper( - "daikoku-query-param", - (id: String, _: Options) => - ctx.request.queryString - .get(id) - .map(_.head) - .getOrElse("id param not found") - ) - daikokuLinks(ctx, handlebars) - - handlebars.registerHelper( - "daikoku-include-block", - (id: String, options: Options) => - daikokuIncludeBlockHelper( - ctx, - Some(page.id.value), - id, - options, - fields, - jsonToCombine, - req - ) - ) - handlebars.registerHelper( - "daikoku-template-wrapper", - (id: String, options: Options) => - daikokuTemplateWrapper( - ctx, - Some(page.id.value), - id, - options, - fields, - jsonToCombine, - req - ) - ) - - enrichHandlebarsWithOwnedApis( - ctx, - Some(page.id.value), - handlebars, - fields, - jsonToCombine, - req - ) - enrichHandlebarsWithOwnedTeams( - ctx, - Some(page.id.value), - handlebars, - fields, - jsonToCombine, - req - ) - - enrichHandlebarsWithEntity( - ctx, - Some(page.id.value), - handlebars, - "api", - _.dataStore.apiRepo, - (api: Api) => api.asJson, - fields, - jsonToCombine, - req - ) - enrichHandlebarsWithEntity( - ctx, - Some(page.id.value), - handlebars, - "team", - _.dataStore.teamRepo, - (team: Team) => team.asJson, - fields, - jsonToCombine, - req - ) - enrichHandlebarWithDocumentationEntity( - ctx, - Some(page.id.value), - handlebars, - "documentation", - fields, - jsonToCombine, - req - ) - enrichHandlebarWithPlanEntity( - ctx, - Some(page.id.value), - handlebars, - "plan", - fields, - jsonToCombine, - req - ) - enrichHandlebarsWithPublicUserEntity( - ctx, - Some(page.id.value), - handlebars, - fields, - jsonToCombine, - req - ) - - val c = context.build() - - val result = handlebars.compileInline(template).apply(c) - c.destroy() - - FastFuture.successful((result, page.contentType)) - } - } catch { - case t: Throwable => - t.printStackTrace() - FastFuture.successful((s""" - - - -

Server error

-
-
${t.getMessage}
-
- - - """, "text/html")) - } - } -} - -object EntitiesToMap { - def request(req: Request[_]) = - Json.obj( - "path" -> req.path, - "method" -> req.method, - "headers" -> req.headers.toSimpleMap - ) -} diff --git a/daikoku/app/env/evolutions.scala b/daikoku/app/env/evolutions.scala index 6a608e651..889d29d7d 100644 --- a/daikoku/app/env/evolutions.scala +++ b/daikoku/app/env/evolutions.scala @@ -8,20 +8,12 @@ import cats.data.OptionT import cats.implicits.catsSyntaxOptionId import fr.maif.otoroshi.daikoku.domain.UsagePlan.FreeWithoutQuotas import fr.maif.otoroshi.daikoku.domain._ -import fr.maif.otoroshi.daikoku.domain.json.{ - ApiDocumentationPageFormat, - ApiFormat, - ApiSubscriptionFormat, - SeqApiDocumentationDetailPageFormat, - TeamFormat, - TeamIdFormat, - TenantFormat, - UserFormat -} +import fr.maif.otoroshi.daikoku.domain.json.{ApiDocumentationPageFormat, ApiFormat, ApiSubscriptionFormat, SeqApiDocumentationDetailPageFormat, TeamFormat, TeamIdFormat, TenantFormat, UserFormat} import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.utils.{IdGenerator, OtoroshiClient} import org.joda.time.DateTime import play.api.libs.json._ +import services.CmsPage import storage.DataStore import scala.concurrent.{ExecutionContext, Future} diff --git a/daikoku/app/messages/Events.scala b/daikoku/app/messages/Events.scala index d4681bb1d..1b8ebe1ac 100644 --- a/daikoku/app/messages/Events.scala +++ b/daikoku/app/messages/Events.scala @@ -10,7 +10,7 @@ import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.utils.Translator import org.joda.time.DateTime import play.api.i18n.{I18nSupport, Lang, MessagesApi} -import play.api.libs.json.{JsArray, JsNull, JsNumber, JsValue, Json} +import play.api.libs.json.{JsArray, JsNull, JsNumber, JsString, JsValue, Json} import scala.concurrent.{ExecutionContext, Future} @@ -123,7 +123,7 @@ class MessageActor(implicit "mail.new.message.title", tenant, Map( - "user" -> sender.get.name + "user" -> JsString(sender.get.name) ) ) body <- translator.translate("mail.new.message.body", tenant) diff --git a/daikoku/app/services/CmsRenderer.scala b/daikoku/app/services/CmsRenderer.scala new file mode 100644 index 000000000..5a3180b0f --- /dev/null +++ b/daikoku/app/services/CmsRenderer.scala @@ -0,0 +1,1565 @@ +package services + +import cats.implicits.catsSyntaxOptionId +import com.github.jknack.handlebars.{Context, Handlebars, Options} +import controllers.AppError +import controllers.AppError.toJson +import domain.JsonNodeValueResolver +import fr.maif.otoroshi.daikoku.actions.{DaikokuActionContext, DaikokuActionMaybeWithoutUserContext, DaikokuInternalActionMaybeWithoutUserContext} +import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent +import fr.maif.otoroshi.daikoku.ctrls.authorizations.async.{_TeamMemberOnly, _UberPublicUserAccess} +import fr.maif.otoroshi.daikoku.domain.{Api, ApiDocumentationPage, ApiWithCount, CanJson, CmsPageId, CommonServices, Team, TeamType, Tenant, TenantId, User, UserId, UserSession, json} +import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.utils.IdGenerator +import org.apache.pekko.http.scaladsl.util.FastFuture +import org.joda.time.DateTime +import play.api.i18n.MessagesApi +import play.api.libs.json.{JsArray, JsBoolean, JsNull, JsNumber, JsObject, JsString, JsValue, Json} +import play.api.mvc.{AnyContent, Request} +import storage.TenantCapableRepo + +import java.util.concurrent.Executors +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, ExecutionContext, Future} + +object CmsPage { + val pageRenderingEc = ExecutionContext.fromExecutor( + Executors.newWorkStealingPool(Runtime.getRuntime.availableProcessors() + 1) + ) +} + +case class CmsFile( + name: String, + content: String, + metadata: Map[String, JsValue] = Map.empty, + daikokuData: Option[Map[String, String]] = None +) { + def path(): String = metadata.getOrElse("_path", JsString("")).as[String] + def contentType(): String = + metadata.getOrElse("_content_type", JsString("")).as[String] + + def authenticated(): Boolean = + Json + .parse(metadata.getOrElse("_authenticated", JsString("false")).as[String]) + .as[Boolean] + def visible(): Boolean = + Json + .parse(metadata.getOrElse("_visible", JsString("true")).as[String]) + .as[Boolean] + def exact(): Boolean = + Json + .parse(metadata.getOrElse("_exact", JsString("false")).as[String]) + .as[Boolean] + + def id() = { + val defaultId = path().replaceAll("/", "-") + daikokuData + .map(data => data.getOrElse("id", defaultId)) + .getOrElse(defaultId) + } + + def toCmsPage(tenantId: TenantId): CmsPage = { + CmsPage( + id = CmsPageId(id()), + tenant = tenantId, + visible = visible(), + authenticated = authenticated(), + exact = exact(), + name = name, + forwardRef = None, + tags = List.empty, + metadata = metadata.map { + case (key, value) => (key, value.toString.replaceAll("\"", "")) + }, + contentType = contentType(), + body = content, + path = Some(path()) + ) + } +} + +case class CmsRequestRendering(content: Seq[CmsFile], current_page: String, fields: Map[String, JsValue]) + +case class CmsPage( + id: CmsPageId, + tenant: TenantId, + deleted: Boolean = false, + visible: Boolean, + authenticated: Boolean, + name: String, + picture: Option[String] = None, + forwardRef: Option[CmsPageId], + tags: List[String], + metadata: Map[String, String], + contentType: String, + body: String, + path: Option[String] = None, + exact: Boolean = false, + lastPublishedDate: Option[DateTime] = None +) extends CanJson[CmsPage] { + override def asJson: JsValue = json.CmsPageFormat.writes(this) + + def enrichHandlebarsWithPublicUserEntity( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + env: Env, + ec: ExecutionContext, + messagesApi: MessagesApi + ): Handlebars = { + handlebars.registerHelper( + s"daikoku-user", + (id: String, options: Options) => { + val userId = renderString(ctx, parentId, id, fields, jsonToCombine, req) + val optUser = + Await.result(env.dataStore.userRepo.findById(userId), 10.seconds) + + optUser match { + case Some(user) => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map( + "user" -> Json.obj( + "_id" -> user.id.value, + "name" -> user.name, + "email" -> user.email, + "picture" -> user.picture + ) + ), + req + ) + case None => AppError.render(AppError.UserNotFound()) + } + } + ) + } + + def enrichHandlebarsWithOwnedApis[A]( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + env: Env, + ec: ExecutionContext, + messagesApi: MessagesApi + ): Handlebars = { + val ctxUserContext = maybeWithoutUserToUserContextConverter(ctx) + val name = "owned-api" + + handlebars.registerHelper( + s"daikoku-${name}s", + (_: CmsPage, options: Options) => { + val visibility = + options.hash.getOrDefault("visibility", "All").asInstanceOf[String] + Await.result( + CommonServices.getVisibleApis( + research = "", + limit = Int.MaxValue, + offset = 0 + )(ctxUserContext, env, ec), + 10.seconds + ) match { + case Right(ApiWithCount(apis, producers, _)) => + apis + .filter(api => + if (visibility == "All") true + else api.api.visibility.name == visibility + ) + .map(api => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map("api" -> api.api.asJson), + req + ) + ) + .mkString("\n") + case Left(error) => AppError.render(error) + } + } + ) + handlebars.registerHelper( + s"daikoku-$name", + (id: String, options: Options) => { + val renderedParameter = + renderString(ctx, parentId, id, fields, jsonToCombine, req) + val version = + options.hash.getOrDefault("version", "1.0.0").asInstanceOf[String] + val optApi = Await.result( + env.dataStore.apiRepo + .findByVersion(ctx.tenant, renderedParameter, version), + 10.seconds + ) + + optApi match { + case Some(api) => + Await.result( + CommonServices + .apiOfTeam(api.team.value, api.id.value, version)( + ctxUserContext, + env, + ec + ) + .map { + case Right(api) => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = + jsonToCombine ++ Map("api" -> api.api.asJson), + req = req + ) + case Left(error) => AppError.render(error) + }, + 10.seconds + ) + case None => AppError.render(AppError.ApiNotFound) + } + } + ) + handlebars.registerHelper( + s"daikoku-json-$name", + (id: String, options: Options) => { + val renderedParameter = + renderString(ctx, parentId, id, fields, jsonToCombine, req) + val version = + options.hash.getOrDefault("version", "1.0.0").asInstanceOf[String] + val optApi = Await.result( + env.dataStore.apiRepo + .findByVersion(ctx.tenant, renderedParameter, version), + 10.seconds + ) + + optApi match { + case Some(api) => + Await.result( + CommonServices + .apiOfTeam(api.team.value, api.id.value, version)( + ctx.asInstanceOf[DaikokuActionContext[Any]], + env, + ec + ) + .map { + case Right(api) => api.api.asJson + case Left(error) => AppError.render(error) + }, + 10.seconds + ) + case None => toJson(AppError.ApiNotFound) + } + } + ) + handlebars.registerHelper( + s"daikoku-json-${name}s", + (_: CmsPage, _: Options) => + Await.result( + CommonServices + .getVisibleApis(research = "", limit = Int.MaxValue, offset = 0)( + maybeWithoutUserToUserContextConverter(ctx), + env, + ec + ) + .map { + case Right(ApiWithCount(apis, producers, _)) => + JsArray(apis.map(_.api.asJson)) + case Left(error) => toJson(error) + }, + 10.seconds + ) + ) + } + + + def maybeWithoutUserToUserContextConverter( + ctx: DaikokuInternalActionMaybeWithoutUserContext[_]): DaikokuActionContext[JsValue] = { + DaikokuActionContext( + request = null, + user = ctx.user.getOrElse( + User( + UserId("Unauthenticated user"), + tenants = Set.empty, + origins = Set.empty, + name = "Unauthenticated user", + email = "unauthenticated@foo.bar", + personalToken = None, + lastTenant = None, + defaultLanguage = None + ) + ), + tenant = ctx.tenant, + session = ctx.session.orNull, + impersonator = ctx.impersonator, + isTenantAdmin = ctx.isTenantAdmin, + apiCreationPermitted = ctx.apiCreationPermitted, + ctx = ctx.ctx + ) + } + + def maybeWithoutUserToUserContext( + tenant: Tenant, + request: Option[Request[JsValue]] = None, + user: Option[User] = None, + session: Option[UserSession] = None, + impersonator: Option[User] = None, + isTenantAdmin: Boolean = false, + apiCreationPermitted: Boolean = false, + ctx: TrieMap[String, String] = TrieMap.empty + ): DaikokuInternalActionMaybeWithoutUserContext[JsValue] = + DaikokuInternalActionMaybeWithoutUserContext( + user = user, + tenant = tenant, + session = session, + impersonator = impersonator, + isTenantAdmin = isTenantAdmin, + apiCreationPermitted = apiCreationPermitted, + ctx = ctx, + requestPath = request.map(_.uri).getOrElse(""), + requestQueryString = request.map(_.queryString).getOrElse(Map.empty), + requestMethod = request.map(_.method).getOrElse("GET"), + requestHeaders = request.map(_.headers.toSimpleMap).getOrElse(Map.empty) + ) + + private def enrichHandlebarsWithOwnedTeams( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + env: Env, + ec: ExecutionContext, + messagesApi: MessagesApi + ): Handlebars = { + val ctxUserContext = maybeWithoutUserToUserContextConverter(ctx) + + handlebars.registerHelper( + s"daikoku-owned-teams", + (_: CmsPage, options: Options) => { + Await.result( + CommonServices.myTeams()(ctxUserContext, env, ec), + 10.seconds + ) match { + case Right(teams) => + teams + .map(team => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map("team" -> team.asJson), + req = req + )(env, ec, messagesApi) + ) + .mkString("\n") + case Left(error) => AppError.render(error) + } + } + ) + handlebars.registerHelper( + s"daikoku-owned-team", + (_: String, options: Options) => { + Await.result( + _UberPublicUserAccess( + AuditTrailEvent( + s"@{user.name} has accessed its first team on @{tenant.name}" + ) + )(ctxUserContext) { + env.dataStore.teamRepo + .forTenant(ctx.tenant.id) + .findOne( + Json.obj( + "_deleted" -> false, + "type" -> TeamType.Personal.name, + "users.userId" -> ctx.user.get.id.value + ) + ) + .map { + case None => AppError.TeamNotFound + case Some(team) if team.includeUser(ctx.user.get.id) => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = + jsonToCombine ++ Map("team" -> team.asSimpleJson), + req = req + ) + case _ => AppError.TeamUnauthorized + } + }, + 10.seconds + ) match { + case Right(e) => e + case Left(error) => toJson(error) + } + } + ) + handlebars.registerHelper( + s"daikoku-json-owned-team", + (id: String, options: Options) => { + val teamId = renderString(ctx, parentId, id, fields, jsonToCombine, req) + + Await.result( + _TeamMemberOnly( + teamId, + AuditTrailEvent( + "@{user.name} has accessed on of his team @{team.name} - @{team.id}" + ) + )(ctxUserContext) { team => + ctx.setCtxValue("team.name", team.name) + ctx.setCtxValue("team.id", team.id) + + FastFuture.successful(Right(team.toUiPayload())) + }, + 10.seconds + ) match { + case Right(jsonTeam) => jsonTeam + case Left(error) => toJson(error) + } + } + ) + handlebars.registerHelper( + s"daikoku-json-owned-teams", + (_: CmsPage, _: Options) => + Await.result( + CommonServices.myTeams()(ctxUserContext, env, ec).map { + case Right(teams) => JsArray(teams.map(_.asJson)) + case Left(error) => toJson(error) + }, + 10.seconds + ) + ) + } + + private def enrichHandlebarsWithEntity[A]( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + name: String, + getRepo: Env => TenantCapableRepo[A, _], + stringify: A => JsValue, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + ec: ExecutionContext, + messagesApi: MessagesApi, + env: Env + ): Handlebars = { + val repo: TenantCapableRepo[A, _] = getRepo(env) + handlebars.registerHelper( + s"daikoku-${name}s", + (_: CmsPage, options: Options) => { + val apis = Await + .result(repo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) + apis + .map(api => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map(name -> stringify(api)), + req + ) + ) + .mkString("\n") + } + ) + handlebars.registerHelper( + s"daikoku-$name", + (id: String, options: Options) => { + Await + .result( + repo + .forTenant(ctx.tenant) + .findByIdOrHrIdNotDeleted( + renderString(ctx, parentId, id, fields, jsonToCombine, req) + ), + 10.seconds + ) + .map(api => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map(name -> stringify(api)), + req + ) + ) + .getOrElse(s"$name not found") + } + ) + handlebars.registerHelper( + s"daikoku-json-$name", + (id: String, _: Options) => + Await + .result(repo.forTenant(ctx.tenant).findByIdNotDeleted(id), 10.seconds) + .map(stringify) + .getOrElse("") + ) + handlebars.registerHelper( + s"daikoku-json-${name}s", + (_: CmsPage, _: Options) => + JsArray( + Await + .result(repo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) + .map(stringify) + ) + ) + } + + private def renderString( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + str: String, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = + Await + .result( + CmsPage( + id = CmsPageId(IdGenerator.token(32)), + tenant = ctx.tenant.id, + visible = true, + authenticated = false, + name = "#generated", + forwardRef = None, + tags = List(), + metadata = Map(), + contentType = "text/html", + body = str, + path = Some("/") + ).render( + ctx, + parentId, + fields = fields, + jsonToCombine = jsonToCombine, + req = req + ), + 10.seconds + ) + ._1 + + private def cmsFindByIdNotDeleted( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + id: String, + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { + req match { + case Some(value) => + value.content + .find(p => cleanPath(p.path()) == cleanPath(id)) + .map(_.toCmsPage(ctx.tenant.id)) + case None => findCmsPageByTheId(ctx, id) + } + } + + private def cleanPath(path: String) = { + val out = path.replace("/_/", "/").replace(".html", "") + if (!path.startsWith("/")) + s"/$out" + else + out + } + + private def findCmsPageByTheId( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + id: String + )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { + Await.result( + env.dataStore.cmsRepo + .forTenant(ctx.tenant) + .findOne( + Json.obj( + "$or" -> Json.arr( + Json.obj("_id" -> cleanPath(id)), + Json.obj("_id" -> cleanPath(id).replace("/", "-")), + Json.obj("_id" -> cleanPath(id).replace("/", "-").substring(1)) + ) + ) + ), + 10.seconds + ) + } + + private def cmsFindById( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + id: String, + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { + req match { + case Some(value) => + value.content + .find(_.path() == id) + .map(_.toCmsPage(ctx.tenant.id)) + case None => findCmsPageByTheId(ctx, id) + } + } + + private def cmsFindOneNotDeleted( + ctx: DaikokuInternalActionMaybeWithoutUserContext[_], + id: String, + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext): Option[CmsPage] = { + req match { + case Some(value) => + value.content + .find(p => cleanPath(p.path()) == cleanPath(id)) + .map(_.toCmsPage(ctx.tenant.id)) + case None => + Await.result( + env.dataStore.cmsRepo + .forTenant(ctx.tenant) + .findOneNotDeleted( + Json.obj( + "$or" -> Json.arr( + Json.obj("path" -> cleanPath(id)), + Json.obj("_id" -> cleanPath(id)), + Json.obj("_id" -> cleanPath(id).replace("/", "-")) + ) + ) + ), + 10.seconds + ) + } + } + + private def daikokuIncludeBlockHelper( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + id: String, + options: Options, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { + val outFields = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) + + cmsFindByIdNotDeleted(ctx, id, req) match { + case None => + cmsFindOneNotDeleted( + ctx, + renderString(ctx, parentId, id, outFields, jsonToCombine, req), + req + ) match { + case None => s"block '$id' not found" + case Some(page) => + Await.result( + page + .render( + ctx, + parentId, + fields = outFields, + jsonToCombine = jsonToCombine, + req = req + ) + .map(t => t._1), + 10.seconds + ) + } + case Some(page) => + Await.result( + page + .render( + ctx, + parentId, + fields = outFields, + jsonToCombine = jsonToCombine, + req + ) + .map(t => t._1), + 10.seconds + ) + } + } + + private def daikokuTemplateWrapper( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + id: String, + options: Options, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { + cmsFindByIdNotDeleted(ctx, id, req) match { + case None => "wrapper component not found" + case Some(page) => + val tmpFields = + getAttrs(ctx, parentId, options, fields, jsonToCombine, req) + val outFields = getAttrs( + ctx, + parentId, + options, + tmpFields ++ Map( + "children" -> Await + .result( + CmsPage( + id = CmsPageId(IdGenerator.token(32)), + tenant = ctx.tenant.id, + visible = true, + authenticated = false, + name = "#generated", + forwardRef = None, + tags = List(), + metadata = Map(), + contentType = "text/html", + body = options.fn.text(), + path = Some("/") + ).render( + ctx, + parentId, + fields = tmpFields, + jsonToCombine = jsonToCombine, + req = req + )(env, messagesApi), + 10.seconds + ) + ._1 + ), + jsonToCombine, + req = req + ) + Await.result( + page + .render( + ctx, + parentId, + fields = outFields, + jsonToCombine = jsonToCombine, + req = req + ) + .map(t => t._1), + 10.seconds + ) + } + } + + private def daikokuPathParam( + ctx: DaikokuActionMaybeWithoutUserContext[_], + id: String, + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext) = { + val pages = req match { + case Some(value) => + value.content + .filter(p => p.path().nonEmpty) + .map(r => s"/_${r.path()}") + case None => + Await + .result( + env.dataStore.cmsRepo + .forTenant(ctx.tenant) + .findWithProjection(Json.obj(), Json.obj("path" -> true)), + 10.seconds + ) + .map(r => s"/_${(r \ "path").as[String]}") + } + + pages + .sortBy(_.length)(Ordering[Int].reverse) + .find(p => ctx.request.path.startsWith(p)) + .map(r => { + val params = ctx.request.path.split(r).filter(f => f.nonEmpty) + try { + if (params.length > 0) + params(0).split("/").filter(_.nonEmpty)(Integer.parseInt(id)) + else + s"path param $id not found" + } catch { + case _: Throwable => s"path param $id not found" + } + }) + .getOrElse(s"path param $id not found") + } + + private def daikokuPageUrl( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + id: String, + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = { + cmsFindByIdNotDeleted(ctx, id, req) match { + case None => "#not-found" + case Some(page) => + var path = page.path.getOrElse("") + + if (!path.startsWith("/")) + path = s"/$path" + + + s"/_${path}" + } + } + + private def daikokuLinks( + ctx: DaikokuInternalActionMaybeWithoutUserContext[_], + handlebars: Handlebars + ) = { + val links = Map( + "login" -> s"/auth/${ctx.tenant.authProvider.name}/login", + "logout" -> "/logout", + "language" -> ctx.user + .map(_.defaultLanguage) + .getOrElse(ctx.tenant.defaultLanguage.getOrElse("en")), + "signup" -> (if (ctx.tenant.authProvider.name == "Local") "/signup" + else s"/auth/${ctx.tenant.authProvider.name}/login"), + "backoffice" -> "/apis", + "notifications" -> "/notifications", + "home" -> "/" + ) + links.map { + case (name, link) => + handlebars.registerHelper( + s"daikoku-links-$name", + (_: Object, _: Options) => link + ) + } + } + + private def getAttrs( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + options: Options, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + env: Env, + ec: ExecutionContext, + messagesApi: MessagesApi + ): Map[String, Any] = { + import scala.jdk.CollectionConverters._ + fields ++ options.hash.asScala.map { + case (k, v) => + ( + k, + renderString( + ctx, + parentId, + v.toString, + fields, + jsonToCombine = jsonToCombine, + req = req + )(env, ec, messagesApi) + ) + }.toMap + } + + private def enrichHandlebarWithPlanEntity( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + name: String, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + ec: ExecutionContext, + messagesApi: MessagesApi, + env: Env + ): Handlebars = { + handlebars.registerHelper( + s" ${name}s-json", + (id: String, _: Options) => { + Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req).flatMap { + case Some(api) => + env.dataStore.usagePlanRepo.findByApi(tenant, api) + case None => FastFuture.successful(Seq.empty) + }, + 10.seconds + ) + .map(_.asJson) + } + ) + + handlebars.registerHelper( + s"daikoku-${name}s", + (id: String, options: Options) => { + Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req).flatMap { + case Some(api) => + env.dataStore.usagePlanRepo.findByApi(tenant, api) + case None => FastFuture.successful(Seq.empty) + }, + 10.seconds + ) + .map(p => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map(name -> p.asJson), + req = req + ) + ) + .mkString("\n") + } + ) + } + + private def getApi( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + id: String, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit env: Env, ec: ExecutionContext, messagesApi: MessagesApi) = + env.dataStore.apiRepo + .forTenant(tenant) + .findByIdOrHrId( + renderString( + ctx, + parentId, + id, + fields, + jsonToCombine = jsonToCombine, + req + )( + env, + ec, + messagesApi + ) + ) + + private def enrichHandlebarWithDocumentationEntity( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String], + handlebars: Handlebars, + name: String, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue], + req: Option[CmsRequestRendering] + )(implicit + ec: ExecutionContext, + messagesApi: MessagesApi, + env: Env + ): Handlebars = { + + def jsonToFields(pages: Seq[ApiDocumentationPage], options: Options) = + pages + .map(doc => + renderString( + ctx, + parentId, + options.fn.text(), + fields, + jsonToCombine = jsonToCombine ++ Map(name -> doc.asJson), + req = req + ) + ) + .mkString("\n") + + handlebars.registerHelper( + s"daikoku-$name", + (id: String, options: Options) => { + val pages = Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req) + .flatMap { + case Some(api) => + Future.sequence( + api.documentation + .docIds() + .map(pageId => + env.dataStore.apiDocumentationPageRepo + .forTenant(ctx.tenant) + .findById(pageId) + ) + ) + case _ => FastFuture.successful(Seq()) + }, + 10.seconds + ) + .flatten + + jsonToFields(pages, options) + } + ) + + handlebars.registerHelper( + s"daikoku-$name-json", + (id: String, options: Options) => { + Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req) + .flatMap { + case Some(api) => + Future.sequence( + api.documentation + .docIds() + .map(pageId => + env.dataStore.apiDocumentationPageRepo + .forTenant(ctx.tenant) + .findById(pageId) + ) + ) + case _ => FastFuture.successful(Seq()) + }, + 10.seconds + ) + .flatten + .map(_.asJson) + } + ) + + handlebars.registerHelper( + s"daikoku-$name-page", + (id: String, options: Options) => { + val attrs = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) + + val page: Int = + attrs.get("page").map(n => n.toString.toInt).getOrElse(0) + val pages = Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req) + .flatMap { + case Some(api) => + Future.sequence( + api.documentation + .docIds() + .slice(page, page + 1) + .map( + env.dataStore.apiDocumentationPageRepo + .forTenant(ctx.tenant) + .findById(_) + ) + ) + case _ => FastFuture.successful(Seq()) + }, + 10.seconds + ) + .flatten + + jsonToFields(pages, options) + } + ) + + handlebars.registerHelper( + s"daikoku-$name-page-id", + (id: String, options: Options) => { + val attrs = getAttrs(ctx, parentId, options, fields, jsonToCombine, req) + val page = attrs.getOrElse("page", "") + Await + .result( + getApi(ctx, parentId, id, fields, jsonToCombine, req) + .flatMap { + case Some(api) => + api.documentation + .docIds() + .find(_ == page) + .map( + env.dataStore.apiDocumentationPageRepo + .forTenant(ctx.tenant) + .findById(_) + ) + .getOrElse(FastFuture.successful(None)) + case _ => FastFuture.successful(None) + }, + 10.seconds + ) + .map(doc => + renderString( + ctx, + parentId, + options.fn.text(), + fields = fields, + jsonToCombine = jsonToCombine ++ Map(name -> doc.asJson), + req = req + ) + ) + .getOrElse("") + } + ) + } + + private def combineFieldsToContext( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + context: Context.Builder, + fields: Map[String, Any], + jsonToCombine: Map[String, JsValue] + )(implicit env: Env, messagesApi: MessagesApi): Context.Builder = + (fields ++ jsonToCombine.map { + case (key, value) => + ( + key, + value match { + case JsNull => null + case boolean: JsBoolean => boolean + case JsNumber(value) => value + case JsString(value) => value + case JsArray(value) => value + case o @ JsObject(underlying) => o + } + ) + }).foldLeft(context) { (acc, item) => + if (item._1 == "email") { + val content = Await.result( + CmsPage( + id = CmsPageId(IdGenerator.token(32)), + tenant = tenant, + visible = true, + authenticated = false, + name = "#generated", + forwardRef = None, + tags = List(), + metadata = Map(), + contentType = "text/html", + body = item._2.toString, + path = Some("/") + ).render( + ctx, + fields = fields - "email", + jsonToCombine = jsonToCombine - "email", + req = None + ), 10.seconds) + acc.combine(item._1, content._1) + } else { + acc.combine(item._1, item._2) + } + } + + private def searchCmsFile( + req: CmsRequestRendering, + page: CmsPage + ): Option[CmsFile] = { + req.content.find(p => p.path() == page.path.getOrElse("")) + } + + def render( + ctx: DaikokuInternalActionMaybeWithoutUserContext[JsValue], + parentId: Option[String] = None, + fields: Map[String, Any] = Map.empty, + jsonToCombine: Map[String, JsValue] = Map.empty, + req: Option[CmsRequestRendering] + )(implicit env: Env, messagesApi: MessagesApi): Future[(String, String)] = { + implicit val ec: ExecutionContext = env.defaultExecutionContext + + val page = forwardRef match { + case Some(id) => cmsFindByIdNotDeleted(ctx, id.value, req).getOrElse(this) + case None => this + } + try { + import com.github.jknack.handlebars.EscapingStrategy + implicit val ec = CmsPage.pageRenderingEc + + if ( + page.authenticated && (ctx.user.isEmpty || ctx.user.exists(_.isGuest)) + ) + ctx.tenant.style.flatMap(_.authenticatedCmsPage) match { + case Some(value) => + cmsFindById(ctx, value, req) match { + case Some(value) => + value.render(ctx, parentId, fields, jsonToCombine, req) + case None => + FastFuture.successful(("Need to be logged", page.contentType)) + } + case None => + FastFuture.successful(("Need to be logged", page.contentType)) + } + else if (parentId.nonEmpty && page.id.value == parentId.get) + FastFuture.successful(("", page.contentType)) + else { + val template = req match { + case Some(value) if page.name != "#generated" => + searchCmsFile(value, page).map(_.content).getOrElse("") + case _ => page.body + } + + var contextBuilder = Context + .newBuilder(this) + .resolver(JsonNodeValueResolver.INSTANCE) + .combine("tenant", ctx.tenant.asJson) + .combine("is_admin", ctx.isTenantAdmin) + .combine("connected", ctx.user.map(!_.isGuest).getOrElse(false)) + .combine("user", ctx.user.map(u => u.asSimpleJson).getOrElse("")) + .combine("request", Json.obj( + "path" -> ctx.requestPath, + "method" -> ctx.requestMethod, + "headers" -> ctx.requestHeaders + )) + .combine( + "daikoku-css", { + if (env.config.isDev) + s"http://localhost:3000/daikoku.css" + else if (env.config.isProd) + s"${env.getDaikokuUrl(ctx.tenant, "/assets/react-app/daikoku.min.css")}" + } + ) + + if (template.contains("{{apis}")) { + contextBuilder = contextBuilder.combine("apis", Json.stringify(JsArray(Await + .result(env.dataStore.apiRepo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) + .map(a => { + a.copy( + description = a.description.replaceAll("\n", "\\n"), + smallDescription = a.smallDescription.replaceAll("\n", "\\n")) + .asJson + })))) + } + + if (template.contains("{{teams}")) { + contextBuilder = contextBuilder.combine("teams", Json.stringify(JsArray(Await + .result(env.dataStore.teamRepo.forTenant(ctx.tenant).findAllNotDeleted(), 10.seconds) + .map(a => { + a.copy(description = a.description.replaceAll("\n", "\\n")).asJson + })))) + } + + if (template.contains("{{users}")) { + contextBuilder = contextBuilder.combine("users", Json.stringify(JsArray(Await + .result(env.dataStore.userRepo.findAllNotDeleted(), 10.seconds) + .map(_.toUiPayload())))) + } + + val context = combineFieldsToContext( + ctx, + contextBuilder, + fields.map { + case (key, value) => + ( + key, + value match { + case JsString(value) => + value // remove quotes framing string + case value => value + } + ) + }, + jsonToCombine + ) + + req match { + case Some(value) if page.name != "#generated" => + searchCmsFile(value, page) + .foreach(_.metadata.foreach(p => { + context.combine( + p._1, + p._2 match { + case JsString(value) => + value // remove quotes framing string + case value => value + } + ) + })) + case _ => + } + + val handlebars = new Handlebars().`with`(new EscapingStrategy() { + override def escape(value: CharSequence): String = { + value.toString + } + }) + + handlebars.registerHelper( + "for", + (variable: String, options: Options) => { + val s = + renderString(ctx, parentId, variable, fields, jsonToCombine, req) + val field = options.hash.getOrDefault("field", "object").toString + + try { + Json + .parse(s) + .as[JsArray] + .value + .map(p => { + renderString( + ctx, + parentId, + options.fn.text(), + fields, + jsonToCombine ++ Map(field -> p), + req = req + ) + }) + .mkString("\n") + } catch { + case _: Throwable => Json.obj() + } + } + ) + handlebars.registerHelper( + "size", + (variable: String, _: Options) => { + val s = + renderString(ctx, parentId, variable, fields, jsonToCombine, req) + try { + String.valueOf(Json.parse(s).asInstanceOf[JsArray].value.length) + } catch { + case _: Throwable => "0" + } + } + ) + handlebars.registerHelper( + "ifeq", + (variable: String, options: Options) => { + if ( + renderString( + ctx, + parentId, + variable, + fields, + jsonToCombine, + req + ) == + renderString( + ctx, + parentId, + options.params(0).toString, + fields, + jsonToCombine, + req + ) + ) + options.fn.apply( + renderString( + ctx, + parentId, + options.fn.text(), + fields, + jsonToCombine, + req = req + ) + ) + else + "" + } + ) + handlebars.registerHelper( + "ifnoteq", + (variable: String, options: Options) => { + if ( + renderString( + ctx, + parentId, + variable, + fields, + jsonToCombine, + req + ) != + renderString( + ctx, + parentId, + options.params(0).toString, + fields, + jsonToCombine, + req + ) + ) + options.fn.apply( + renderString( + ctx, + parentId, + options.fn.text(), + fields, + jsonToCombine, + req + ) + ) + else + "" + } + ) + handlebars.registerHelper( + "getOrElse", + (variable: String, options: Options) => { + val str = + renderString(ctx, parentId, variable, fields, jsonToCombine, req) + if (str != "null" && str.nonEmpty) + str + else + renderString( + ctx, + parentId, + options.params(0).toString, + fields, + jsonToCombine, + req + ) + } + ) + handlebars.registerHelper( + "translate", + (variable: String, _: Options) => { + val str = + renderString(ctx, parentId, variable, fields, jsonToCombine, req) + Await.result( + env.translator.translate(str, ctx.tenant)( + messagesApi, + ctx.user + .map( + _.defaultLanguage + .getOrElse(ctx.tenant.defaultLanguage.getOrElse("en")) + ) + .getOrElse("en"), + env + ), + 10.seconds + ) + } + ) + handlebars.registerHelper( + "daikoku-asset-url", + (context: String, _: Options) => s"/tenant-assets/$context" + ) + handlebars.registerHelper( + "daikoku-page-url", + (id: String, _: Options) => daikokuPageUrl(ctx, id, req) + ) + handlebars.registerHelper( + "daikoku-generic-page-url", + (id: String, _: Options) => s"/cms/pages/$id" + ) + handlebars.registerHelper( + "daikoku-query-param", + (id: String, _: Options) => + ctx.requestQueryString + .get(id) + .map(_.head) + .getOrElse("id param not found") + ) + daikokuLinks(ctx, handlebars) + + handlebars.registerHelper( + "daikoku-include-block", + (id: String, options: Options) => + daikokuIncludeBlockHelper( + ctx, + Some(page.id.value), + id, + options, + fields, + jsonToCombine, + req + ) + ) + handlebars.registerHelper( + "daikoku-template-wrapper", + (id: String, options: Options) => + daikokuTemplateWrapper( + ctx, + Some(page.id.value), + id, + options, + fields, + jsonToCombine, + req + ) + ) + + enrichHandlebarsWithOwnedApis( + ctx, + Some(page.id.value), + handlebars, + fields, + jsonToCombine, + req + ) + enrichHandlebarsWithOwnedTeams( + ctx, + Some(page.id.value), + handlebars, + fields, + jsonToCombine, + req + ) + + enrichHandlebarsWithEntity( + ctx, + Some(page.id.value), + handlebars, + "api", + _.dataStore.apiRepo, + (api: Api) => api.asJson, + fields, + jsonToCombine, + req + ) + enrichHandlebarsWithEntity( + ctx, + Some(page.id.value), + handlebars, + "team", + _.dataStore.teamRepo, + (team: Team) => team.asJson, + fields, + jsonToCombine, + req + ) + enrichHandlebarWithDocumentationEntity( + ctx, + Some(page.id.value), + handlebars, + "documentation", + fields, + jsonToCombine, + req + ) + enrichHandlebarWithPlanEntity( + ctx, + Some(page.id.value), + handlebars, + "plan", + fields, + jsonToCombine, + req + ) + enrichHandlebarsWithPublicUserEntity( + ctx, + Some(page.id.value), + handlebars, + fields, + jsonToCombine, + req + ) + + val c = context.build() + + val result = handlebars.compileInline(template).apply(c) + c.destroy() + + FastFuture.successful((result, page.contentType)) + } + } catch { + case t: Throwable => + t.printStackTrace() + FastFuture.successful((s""" + + + +

Server error

+
+
${t.getMessage}
+
+ + + """, "text/html")) + } + } +} diff --git a/daikoku/app/storage/api.scala b/daikoku/app/storage/api.scala index 0081e59af..d70162454 100644 --- a/daikoku/app/storage/api.scala +++ b/daikoku/app/storage/api.scala @@ -1,7 +1,6 @@ package storage import org.apache.pekko.NotUsed -import org.apache.pekko.http.scaladsl.util.FastFuture import org.apache.pekko.stream.Materializer import org.apache.pekko.stream.scaladsl.Source import org.apache.pekko.util.ByteString @@ -9,6 +8,7 @@ import cats.data.OptionT import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.env.Env import play.api.libs.json._ +import services.CmsPage import scala.concurrent.{ExecutionContext, Future} diff --git a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala index fc0f03dbb..f8a07da8a 100644 --- a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala +++ b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala @@ -17,6 +17,7 @@ import io.vertx.pgclient.{PgConnectOptions, PgPool, SslMode} import io.vertx.sqlclient.PoolOptions import play.api.libs.json._ import play.api.{Configuration, Logger} +import services.CmsPage import storage._ import storage.drivers.postgres.Helper._ import storage.drivers.postgres.pgimplicits.EnhancedRow diff --git a/daikoku/app/utils/ApiService.scala b/daikoku/app/utils/ApiService.scala index 2e543b473..76a1640af 100644 --- a/daikoku/app/utils/ApiService.scala +++ b/daikoku/app/utils/ApiService.scala @@ -1083,8 +1083,8 @@ class ApiService( "mail.apikey.refresh.body", tenant, Map( - "apiName" -> api.name, - "planName" -> plan.customName.getOrElse(plan.typeName) + "apiName" -> JsString(api.name), + "planName" -> JsString(plan.customName.getOrElse(plan.typeName)) ) ) } yield { @@ -1704,10 +1704,10 @@ class ApiService( "mail.apikey.demand.body", tenant, Map( - "user" -> user.name, - "apiName" -> api.name, - "link" -> notificationUrl, - "team" -> demandTeam.name + "user" -> JsString(user.name), + "apiName" -> JsString(api.name), + "link" -> JsString(notificationUrl), + "team" -> JsString(demandTeam.name) ) ) } yield { @@ -1955,11 +1955,11 @@ class ApiService( "mail.subscription.validation.body", tenant, Map( - "urlAccept" -> env.getDaikokuUrl(tenant, pathAccept), - "urlDecline" -> env.getDaikokuUrl(tenant, pathDecline), - "user" -> user.name, - "team" -> team.name, - "body" -> template.getOrElse("") + "urlAccept" -> JsString(env.getDaikokuUrl(tenant, pathAccept)), + "urlDecline" -> JsString(env.getDaikokuUrl(tenant, pathDecline)), + "user" -> JsString(user.name), + "team" -> JsString(team.name), + "body" -> JsString(template.getOrElse("")) ) ) .flatMap(body => @@ -2094,12 +2094,12 @@ class ApiService( "mail.checkout.body", tenant, Map( - "api.name" -> api.name, - "api.plan" -> plan.customName.getOrElse(plan.typeName), - "link" -> env.getDaikokuUrl( + "api.name" -> JsString(api.name), + "api.plan" -> JsString(plan.customName.getOrElse(plan.typeName)), + "link" -> JsString(env.getDaikokuUrl( tenant, s"/api/subscription/team/${team.id.value}/demands/${demand.id.value}/_run" - ) + )) ) ) ) @@ -2218,13 +2218,13 @@ class ApiService( "mail.api.subscription.acceptation.body", tenant, Map( - "user" -> from.name, - "apiName" -> api.name, - "link" -> env.getDaikokuUrl( + "user" -> JsString(from.name), + "apiName" -> JsString(api.name), + "link" -> JsString(env.getDaikokuUrl( tenant, s"/${team.humanReadableId}/settings/apikeys/${api.humanReadableId}/${api.currentVersion.value}" - ), //todo => better url - "team" -> demandTeam.name + )), //todo => better url + "team" -> JsString(demandTeam.name) ) ) } yield { @@ -2597,10 +2597,10 @@ class ApiService( "mail.api.subscription.rejection.body", tenant, Map( - "user" -> from.name, - "apiName" -> api.name, - "team" -> team.name, - "message" -> maybeMessage.getOrElse("") + "user" -> JsString(from.name), + "apiName" -> JsString(api.name), + "team" -> JsString(team.name), + "message" -> JsString(maybeMessage.getOrElse("")) ) ) } yield { diff --git a/daikoku/app/utils/Translator.scala b/daikoku/app/utils/Translator.scala index 5836ae7ea..b2a18c9f3 100644 --- a/daikoku/app/utils/Translator.scala +++ b/daikoku/app/utils/Translator.scala @@ -1,26 +1,25 @@ package fr.maif.otoroshi.daikoku.utils import org.apache.pekko.http.scaladsl.util.FastFuture -import fr.maif.otoroshi.daikoku.domain.Tenant +import fr.maif.otoroshi.daikoku.domain.{CmsPageId, Tenant} import fr.maif.otoroshi.daikoku.env.Env import play.api.i18n.{Lang, MessagesApi} -import play.api.libs.json.Json +import play.api.libs.json.{JsArray, JsBoolean, JsNull, JsNumber, JsObject, JsString, JsValue, Json} +import services.CmsPage -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{Await, ExecutionContext, Future} class Translator { private def getTranslation( key: String, - tenant: Tenant, - args: Map[String, String] = Map.empty + tenant: Tenant )(implicit messagesApi: MessagesApi, language: String, env: Env ): Future[String]= { implicit val ec = env.defaultExecutionContext - implicit val mat = env.defaultMaterializer env.dataStore.translationRepo .forTenant(tenant) @@ -34,7 +33,7 @@ class Translator { def translate( key: String, tenant: Tenant, - args: Map[String, String] = Map.empty + args: Map[String, JsValue] = Map.empty )(implicit messagesApi: MessagesApi, language: String, @@ -48,20 +47,54 @@ class Translator { .findOne(Json.obj("_id" -> s".mails.$key.${language.toLowerCase}".replaceAll("\\.", "-"))) .flatMap { case None => - getTranslation(key, tenant, args) + getTranslation(key, tenant) case Some(cmsPage) => FastFuture.successful(cmsPage.body) } } else { - getTranslation(key, tenant, args) + getTranslation(key, tenant) } - body.map { value => + renderTranslationAsCmsPage(body, tenant, args) + } + + def renderTranslationAsCmsPage(value: Future[String], + tenant: Tenant, + args: Map[String, JsValue] = Map.empty) + (implicit + env: Env, + messagesApi: MessagesApi + ): Future[String] = { + implicit val ec: ExecutionContext = env.defaultExecutionContext + value.map(value => replaceVariables(value, args)) + .flatMap(content => { + val page = CmsPage( + id = CmsPageId(IdGenerator.token(32)), + tenant = tenant.id, + visible = true, + authenticated = false, + name = "#generated", + forwardRef = None, + tags = List(), + metadata = Map(), + contentType = "text/html", + body = content, + path = Some("/") + ) + + page.render( + page.maybeWithoutUserToUserContext(tenant), + req = None, + fields = args + ) + }.map(_._1)) + } + + def replaceVariables(value: String, args: Map[String, JsValue]): String = { args.foldLeft(value) { (acc, a) => - acc.replace(s"[${a._1}]", a._2) + acc.replace(s"[${a._1}]", a._2.toString) } - } } def _getMailTemplate(key: String, tenant: Tenant)(implicit @@ -79,19 +112,19 @@ class Translator { .flatMap { case None => tenant.mailerSettings match { - case None => translate(key, tenant, Map("email" -> defaultTemplate)) + case None => translate(key, tenant, Map("email" -> JsString(defaultTemplate))) case Some(mailer) => mailer.template .map(t => FastFuture.successful(t)) .getOrElse( - translate(key, tenant, Map("email" -> defaultTemplate)) + translate(key, tenant, Map("email" -> JsString(defaultTemplate))) ) } case Some(translation) => FastFuture.successful(translation.value) } } - def getMailTemplate(key: String, tenant: Tenant)(implicit + def getMailTemplate(key: String, tenant: Tenant, args: Map[String, JsValue])(implicit language: String, env: Env, messagesApi: MessagesApi @@ -104,7 +137,7 @@ class Translator { .flatMap { case None => _getMailTemplate(key, tenant) case Some(cmsPage) => - FastFuture.successful(cmsPage.body) + renderTranslationAsCmsPage(FastFuture.successful(cmsPage.body), tenant, args) } } } diff --git a/daikoku/app/utils/emails.scala b/daikoku/app/utils/emails.scala index 977bb4131..0087cc39c 100644 --- a/daikoku/app/utils/emails.scala +++ b/daikoku/app/utils/emails.scala @@ -55,16 +55,17 @@ class ConsoleMailer(settings: ConsoleMailerSettings) extends Mailer { env: Env, language: String ): Future[Unit] = { - translator - .getMailTemplate("tenant.mail.template", tenant) + .getMailTemplate("tenant.mail.template", tenant, Map("email" -> JsString(body))) .map { templateBody => logger.info(s"Sent email: ${Json.prettyPrint( Json.obj( "from" -> s"Daikoku ", "to" -> Seq(to.mkString(", ")), "subject" -> Seq(title), - "html" -> templateBody.replace("{{email}}", body) + "html" -> templateBody + .replace("{{email}}", body) + .replace("[email]", body) ) )}") () @@ -87,7 +88,7 @@ class MailgunSender(wsClient: WSClient, settings: MailgunSettings) ): Future[Unit] = { translator - .getMailTemplate("tenant.mail.template", tenant) + .getMailTemplate("tenant.mail.template", tenant, Map("email" -> JsString(body))) .map(templatedBody => { wsClient .url(if (settings.eu) { @@ -101,7 +102,7 @@ class MailgunSender(wsClient: WSClient, settings: MailgunSettings) "from" -> Seq(s"${settings.fromTitle} <${settings.fromEmail}>"), "to" -> Seq(to.mkString(", ")), "subject" -> Seq(title), - "html" -> Seq(templatedBody.replace("{{email}}", body)), + "html" -> Seq(templatedBody.replace("{{email}}", body).replace("[email]", body)), "text" -> Seq(body) ) ) @@ -131,7 +132,7 @@ class MailjetSender(wsClient: WSClient, settings: MailjetSettings) ): Future[Unit] = { translator - .getMailTemplate("tenant.mail.template", tenant) + .getMailTemplate("tenant.mail.template", tenant, Map("email" -> JsString(body))) .map(templatedBody => { wsClient .url(s"https://api.mailjet.com/v3.1/send") @@ -158,7 +159,7 @@ class MailjetSender(wsClient: WSClient, settings: MailjetSettings) ) ), "Subject" -> title, - "HTMLPart" -> templatedBody.replace("{{email}}", body) + "HTMLPart" -> templatedBody.replace("{{email}}", body).replace("[email]", body) // TextPart ) ) @@ -193,7 +194,7 @@ class SimpleSMTPSender(settings: SimpleSMTPSettings) extends Mailer { ): Future[Unit] = { translator - .getMailTemplate("tenant.mail.template", tenant) + .getMailTemplate("tenant.mail.template", tenant, Map("email" -> JsString(body))) .map(templatedBody => { val properties = new Properties() @@ -218,7 +219,7 @@ class SimpleSMTPSender(settings: SimpleSMTPSettings) extends Mailer { message.setSentDate(new Date()) message.setSubject(title) message.setContent( - templatedBody.replace("{{email}}", body), + templatedBody.replace("{{email}}", body).replace("[email]", body), "text/html; charset=utf-8" ) @@ -255,7 +256,7 @@ class SendgridSender(ws: WSClient, settings: SendgridSettings) extends Mailer { ): Future[Unit] = { translator - .getMailTemplate("tenant.mail.template", tenant) + .getMailTemplate("tenant.mail.template", tenant, Map("email" -> JsString(body))) .map(templatedBody => { ws.url(s"https://api.sendgrid.com/v3/mail/send") .withHttpHeaders( @@ -277,7 +278,7 @@ class SendgridSender(ws: WSClient, settings: SendgridSettings) extends Mailer { "content" -> Json.arr( Json.obj( "type" -> "text/html", - "value" -> templatedBody.replace("{{email}}", body) + "value" -> templatedBody.replace("{{email}}", body).replace("[email]", body) ) ) ) diff --git a/daikoku/test/daikoku/AdminApiControllerSpec.scala b/daikoku/test/daikoku/AdminApiControllerSpec.scala index e279aa34d..754fd7c4a 100644 --- a/daikoku/test/daikoku/AdminApiControllerSpec.scala +++ b/daikoku/test/daikoku/AdminApiControllerSpec.scala @@ -11,6 +11,7 @@ import org.scalatest.{BeforeAndAfter, BeforeAndAfterEach} import org.scalatestplus.play.PlaySpec import play.api.libs.json.{JsObject, Json} import play.api.libs.ws.WSResponse +import services.CmsPage import java.util.Base64 import scala.concurrent.duration.DurationInt diff --git a/daikoku/test/daikoku/suites.scala b/daikoku/test/daikoku/suites.scala index 541774f65..2049a7517 100644 --- a/daikoku/test/daikoku/suites.scala +++ b/daikoku/test/daikoku/suites.scala @@ -22,6 +22,7 @@ import org.scalatestplus.play.components.OneServerPerSuiteWithComponents import play.api.libs.json.{JsArray, JsNull, JsObject, JsValue, Json} import play.api.libs.ws.{DefaultWSCookie, WSResponse} import play.api.{Application, BuiltInComponents, Logger} +import services.CmsPage import java.io.File import java.nio.charset.StandardCharsets diff --git a/manual/docs/04-cli/042-apis-to-business-website/04-create-your-first-page copy.mdx b/manual/docs/04-cli/042-apis-to-business-website/04-create-your-first-page copy.mdx index 9e918eaa9..6300f8f49 100644 --- a/manual/docs/04-cli/042-apis-to-business-website/04-create-your-first-page copy.mdx +++ b/manual/docs/04-cli/042-apis-to-business-website/04-create-your-first-page copy.mdx @@ -39,8 +39,8 @@ To make it easier to preview all your pages, add HTML page navigation links befo `

` at the top of both of your pages (page.html and about.html): ```html title="src/pages/about.html" -Home -About +Home +About

About Me

... and my new Daikoku site!

diff --git a/manual/docs/04-cli/042-apis-to-business-website/06-style-your-apis-page.mdx b/manual/docs/04-cli/042-apis-to-business-website/06-style-your-apis-page.mdx index 204ba40cf..784fbef3a 100644 --- a/manual/docs/04-cli/042-apis-to-business-website/06-style-your-apis-page.mdx +++ b/manual/docs/04-cli/042-apis-to-business-website/06-style-your-apis-page.mdx @@ -15,7 +15,7 @@ _exact: true // addition // addition - + // addition @@ -51,7 +51,7 @@ Writing a Daikoku template is very much like writing HTML, so you can use all HT // deletion - + // addition // addition diff --git a/manual/docs/04-cli/042-apis-to-business-website/09-create-react-component.mdx b/manual/docs/04-cli/042-apis-to-business-website/09-create-react-component.mdx index 59aa56b5c..ac9b65c2c 100644 --- a/manual/docs/04-cli/042-apis-to-business-website/09-create-react-component.mdx +++ b/manual/docs/04-cli/042-apis-to-business-website/09-create-react-component.mdx @@ -82,7 +82,7 @@ _exact: true --- //addition - + {{#daikoku-template-wrapper "/layouts/react-base.html" coucou="salut"}}

My apis {{coucou}}