From 0be390f122aa2436c6ef08566c1ba3688e905661 Mon Sep 17 00:00:00 2001 From: Etienne ANNE Date: Wed, 18 Sep 2024 09:17:38 +0200 Subject: [PATCH] cli wip --- cli/src/commands/commands.md | 1 + daikoku/app/actions/actions.scala | 137 ++++- daikoku/app/controllers/ApiController.scala | 26 +- .../app/controllers/CmsApiController.scala | 530 ++++++++++++++++++ daikoku/app/controllers/HomeController.scala | 354 +----------- daikoku/app/controllers/LoginController.scala | 65 +-- .../controllers/TranslationController.scala | 60 +- daikoku/app/daikoku.scala | 28 +- daikoku/app/domain/SchemaDefinition.scala | 6 - daikoku/app/domain/json.scala | 16 +- daikoku/app/domain/tenantEntities.scala | 7 +- daikoku/app/env/env.scala | 80 +++ daikoku/app/env/evolutions.scala | 104 +++- daikoku/app/services/ApisService.scala | 17 + .../app/services/TranslationsService.scala | 94 ++++ daikoku/app/utils/ApiService.scala | 25 + daikoku/conf/routes | 32 +- daikoku/javascript/src/apps/DaikokuApp.tsx | 17 +- .../javascript/src/apps/DaikokuHomeApp.tsx | 4 +- .../components/adminbackoffice/cms/index.tsx | 10 +- .../tenants/forms/SecurityForm.tsx | 6 - .../src/components/frontend/MaybeHomePage.tsx | 14 +- .../javascript/src/components/utils/login.tsx | 4 +- daikoku/javascript/vite.config.ts | 1 + daikoku/public/swaggers/cms-api-openapi.json | 163 ++++++ 25 files changed, 1250 insertions(+), 551 deletions(-) create mode 100644 daikoku/app/controllers/CmsApiController.scala create mode 100644 daikoku/app/services/ApisService.scala create mode 100644 daikoku/app/services/TranslationsService.scala create mode 100644 daikoku/public/swaggers/cms-api-openapi.json diff --git a/cli/src/commands/commands.md b/cli/src/commands/commands.md index 88fc382d4..d988ca8ca 100644 --- a/cli/src/commands/commands.md +++ b/cli/src/commands/commands.md @@ -40,6 +40,7 @@ daikoku login # PULL daikoku pull apis daikoku pull apis +daikoku pull mails # VERSION daikoku version diff --git a/daikoku/app/actions/actions.scala b/daikoku/app/actions/actions.scala index dd267b3dc..8846d9d99 100644 --- a/daikoku/app/actions/actions.scala +++ b/daikoku/app/actions/actions.scala @@ -2,9 +2,16 @@ package fr.maif.otoroshi.daikoku.actions import org.apache.pekko.http.scaladsl.util.FastFuture import cats.implicits.catsSyntaxOptionId +import com.auth0.jwt.JWT +import com.google.common.base.Charsets +import fr.maif.otoroshi.daikoku.ctrls.CmsApiActionContext import fr.maif.otoroshi.daikoku.domain.TeamPermission.{Administrator, ApiEditor} import fr.maif.otoroshi.daikoku.domain._ -import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.env.{ + Env, + LocalCmsApiConfig, + OtoroshiCmsApiConfig +} import fr.maif.otoroshi.daikoku.login.{IdentityAttrs, TenantHelper} import fr.maif.otoroshi.daikoku.utils.Errors import fr.maif.otoroshi.daikoku.utils.RequestImplicits.EnhancedRequestHeader @@ -12,8 +19,10 @@ import play.api.Logger import play.api.libs.json.{JsString, JsValue, Json} import play.api.mvc._ +import java.util.Base64 import scala.collection.concurrent.TrieMap import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Success, Try} object tenantSecurity { def userCanCreateApi(tenant: Tenant, user: User)(implicit @@ -95,16 +104,23 @@ case class DaikokuActionMaybeWithoutUserContext[A]( } } +trait ApiActionContext[A] { + def request: Request[A] + def user: User + def tenant: Tenant + def ctx: TrieMap[String, String] = new TrieMap[String, String]() +} + case class DaikokuActionContext[A]( - request: Request[A], - user: User, - tenant: Tenant, - session: UserSession, - impersonator: Option[User], - isTenantAdmin: Boolean, - apiCreationPermitted: Boolean = false, - ctx: TrieMap[String, String] = new TrieMap[String, String]() -) { + request: Request[A], + user: User, + tenant: Tenant, + session: UserSession, + impersonator: Option[User], + isTenantAdmin: Boolean, + apiCreationPermitted: Boolean = false, + override val ctx: TrieMap[String, String] = new TrieMap[String, String]() + ) extends ApiActionContext[A] { def setCtxValue(key: String, value: Any): Unit = { if (value != null) { ctx.put(key, value.toString) @@ -112,6 +128,107 @@ case class DaikokuActionContext[A]( } } +class CmsApiAction(val parser: BodyParser[AnyContent], env: Env) + extends ActionBuilder[CmsApiActionContext, AnyContent] + with ActionFunction[Request, CmsApiActionContext] { + + implicit lazy val ec: ExecutionContext = env.defaultExecutionContext + + def decodeBase64(encoded: String): String = + new String(Base64.getUrlDecoder.decode(encoded), Charsets.UTF_8) + private def extractUsernamePassword( + header: String + ): Option[(String, String)] = { + val base64 = header.replace("Basic ", "").replace("basic ", "") + Option(base64) + .map(decodeBase64) + .map(_.split(":").toSeq) + .flatMap(a => + a.headOption.flatMap(head => a.lastOption.map(last => (head, last))) + ) + } + + override def invokeBlock[A]( + request: Request[A], + block: CmsApiActionContext[A] => Future[Result] + ): Future[Result] = { + TenantHelper.withTenant(request, env) { tenant => + env.config.cmsApiConfig match { + case OtoroshiCmsApiConfig(headerName, algo) => + request.headers.get(headerName) match { + case Some(value) => + Try(JWT.require(algo).build().verify(value)) match { + case Success(decoded) if !decoded.getClaim("apikey").isNull => + block(CmsApiActionContext[A](request, tenant)) + case _ => + Errors.craftResponseResult( + "No api key provided", + Results.Unauthorized, + request, + None, + env + ) + } + case _ => + Errors.craftResponseResult( + "No api key provided", + Results.Unauthorized, + request, + None, + env + ) + } + case LocalCmsApiConfig(_) => + request.headers.get("Authorization") match { + case Some(auth) if auth.startsWith("Basic ") => + extractUsernamePassword(auth) match { + case None => + Errors.craftResponseResult( + "No api key provided", + Results.Unauthorized, + request, + None, + env + ) + case Some((clientId, clientSecret)) => + env.dataStore.apiSubscriptionRepo + .forTenant(tenant) + .findNotDeleted( + Json.obj( + "apiKey.clientId" -> clientId, + "apiKey.clientSecret" -> clientSecret + ) + ) + .map(_.length == 1) + .flatMap({ + case done if done => + block(CmsApiActionContext[A](request, tenant)) + case _ => + Errors.craftResponseResult( + "No api key provided", + Results.Unauthorized, + request, + None, + env + ) + }) + } + case _ => + Errors.craftResponseResult( + "No api key provided", + Results.Unauthorized, + request, + None, + env + ) + } + } + } + } + + override protected def executionContext: ExecutionContext = ec +} + class DaikokuAction(val parser: BodyParser[AnyContent], env: Env) extends ActionBuilder[DaikokuActionContext, AnyContent] with ActionFunction[Request, DaikokuActionContext] { diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 2ae2714b0..985825f7e 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -1016,29 +1016,7 @@ class ApiController( s"@{user.name} has fetch all apis" ) )(ctx.tenant.id.value, ctx) { (tenant, _) => - env.dataStore.apiRepo.forTenant(tenant) - .findAll() - .map(apis => { - val fields: Seq[String] = ctx.request.getQueryString("fields").map(_.split(",").toSeq).getOrElse(Seq.empty[String]) - val hasFields = fields.nonEmpty - if (hasFields) { - Ok(JsArray(apis.map(api => { - val jsonAPI = api.asJson - val content = jsonAPI match { - case arr @ JsArray(_) => - JsArray(arr.value.map { item => - JsonOperationsHelper.filterJson(item.as[JsObject], fields) - }) - case obj @ JsObject(_) => JsonOperationsHelper.filterJson(obj, fields) - case _ => jsonAPI - } - - content - }))) - } else { - Ok(SeqApiFormat.writes(apis)) - } - }) + apiService.getApis(ctx) } } @@ -4418,7 +4396,7 @@ class ApiController( env.dataStore.apiRepo .findAllVersions(tenant = ctx.tenant, id = apiId) .map { apis => - ctx.setCtxValue("api.name", apis.head.name) + ctx.setCtxValue("api.name", apis.headOption.map(_.name).getOrElse("unknown api name")) ctx.setCtxValue("api.id", apiId) Ok( SeqVersionFormat.writes( diff --git a/daikoku/app/controllers/CmsApiController.scala b/daikoku/app/controllers/CmsApiController.scala new file mode 100644 index 000000000..7c4ce648d --- /dev/null +++ b/daikoku/app/controllers/CmsApiController.scala @@ -0,0 +1,530 @@ +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.domain.json.{CmsFileFormat, CmsPageFormat} +import fr.maif.otoroshi.daikoku.domain.{ + CmsPage, + CmsPageId, + Tenant, + TenantMode, + User, + UserSession +} +import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.logger.AppLogger +import fr.maif.otoroshi.daikoku.login.AuthProvider.{OAuth2, Otoroshi} +import fr.maif.otoroshi.daikoku.login.{IdentityAttrs, OAuth2Config} +import fr.maif.otoroshi.daikoku.services.TranslationsService +import fr.maif.otoroshi.daikoku.utils.ApiService +import fr.maif.otoroshi.daikoku.utils.Cypher.encrypt +import fr.maif.otoroshi.daikoku.utils.admin.UpdateOrCreate +import fr.maif.otoroshi.daikoku.utils.future.EnhancedObject +import org.apache.pekko.http.scaladsl.util.FastFuture +import play.api.Logger +import play.api.libs.json._ +import play.api.mvc._ +import storage.{DataStore, Repo} + +import scala.collection.concurrent.TrieMap +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Using} + +case class CmsApiActionContext[A]( + request: Request[A], + tenant: Tenant, + override val ctx: TrieMap[String, String] = TrieMap.empty[String, String], + override val user: User = null +) extends ApiActionContext[A] + +class CmsApiController( + CmsApiAction: CmsApiAction, + env: Env, + cc: ControllerComponents, + DaikokuActionMaybeWithoutUser: DaikokuActionMaybeWithoutUser, + translationsService: TranslationsService, + apiService: ApiService, + assets: Assets +) extends AbstractController(cc) { + + implicit val ec: ExecutionContext = env.defaultExecutionContext + implicit val ev: Env = env + + val logger: Logger = Logger(s"cms-controller-$entityName") + + private def entityClass = classOf[CmsPage] +// def description: String = entityClass.getName +// def pathRoot: String = "/cms-api" + def entityName: String = "api-cms-page" + def entityStore(tenant: Tenant, ds: DataStore): Repo[CmsPage, CmsPageId] = + ds.cmsRepo.forTenant(tenant) + def toJson(entity: CmsPage): JsValue = entity.asJson + + def fromJson(entity: JsValue): Either[String, CmsPage] = + CmsPageFormat + .reads(entity) + .asEither + .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + def validate( + entity: CmsPage, + updateOrCreate: UpdateOrCreate + ): EitherT[Future, AppError, CmsPage] = EitherT.pure[Future, AppError](entity) + + def getId(entity: CmsPage): CmsPageId = entity.id + + def getCmsPage(id: String) = + CmsApiAction.async { ctx => + env.dataStore.cmsRepo + .forTenant(ctx.tenant) + .findById(id) + .map { + case None => NotFound(Json.obj("error" -> "cms page not found")) + case Some(page) => Ok(page.asJson) + } + } + + def synchronizeWithLocalBundle() = + CmsApiAction.async { ctx => + Ok(Json.obj("message" -> "synchronization done")).future + } + + def sync() = CmsApiAction.async(parse.json) { ctx => + for { + _ <- env.dataStore.cmsRepo.forTenant(ctx.tenant).deleteAll() + _ <- Future.sequence( + ctx.request.body + .as(Reads.seq(CmsFileFormat.reads)) + .map(page => { + env.dataStore.cmsRepo + .forTenant(ctx.tenant) + .save(page.toCmsPage(ctx.tenant.id)) + }) + ) + } yield { + NoContent + } + } + +// def sync() = +// CmsApiAction.async(parse.json) { ctx => +// val body = ctx.request.body +// +// Future +// .sequence( +// body +// .as(Reads.seq(CmsFileFormat.reads)) +// .map(page => { +// env.dataStore.cmsRepo +// .forTenant(ctx.tenant) +// .save(page.toCmsPage(ctx.tenant.id)) +// }) +// ) +// .map(_ => NoContent) +// .recover { +// case e: Throwable => +// BadRequest(Json.obj("error" -> e.getMessage)) +// } +// } + + def health() = + CmsApiAction.async { ctx => + ctx.request.headers.get("Otoroshi-Health-Check-Logic-Test") match { + //todo: better health check + case Some(value) => + Ok.withHeaders( + "Otoroshi-Health-Check-Logic-Test-Result" -> (value.toLong + 42L).toString + ) + .future + case None => + Ok( + Json.obj( + "tenantMode" -> ctx.tenant.tenantMode + .getOrElse(TenantMode.Default) + .name + ) + ).withHeaders("Content-Type" -> "application/json") + .future + } + } + + def version() = + CmsApiAction.async { ctx => + entityStore(ctx.tenant, env.dataStore) + .exists(Json.obj("_id" -> "daikoku_metadata")) + .map { + case true => Ok(Json.obj()) + case false => NotFound + } + } + + def defaultTenant() = + CmsApiAction.async { ctx => + env.dataStore.translationRepo + .forTenant(ctx.tenant) + .find(Json.obj("element.id" -> ctx.tenant.id.asJson)) + .map(translations => { + val translationAsJsObject = translations + .groupBy(t => t.language) + .map { + case (k, v) => + Json + .obj(k -> JsObject(v.map(t => t.key -> JsString(t.value)))) + } + .fold(Json.obj())(_ deepMerge _) + val translation = Json.obj("translation" -> translationAsJsObject) + Ok(ctx.tenant.asJsonWithJwt.as[JsObject] ++ translation) + }) + } + + def findAll(): Action[AnyContent] = + CmsApiAction.async { ctx => + entityStore(ctx.tenant, env.dataStore) + .findAllNotDeleted() + .map(entities => Ok(JsArray(entities.map(_.asJson)))) + } + + def getMailTranslations(domain: Option[String]) = + CmsApiAction.async { ctx => + translationsService.getMailTranslations( + ctx, + domain, + messagesApi, + supportedLangs + ) + } + + def getAllApis() = + CmsApiAction.async { ctx => + apiService.getApis(ctx) + } + + def getLoginToken() = + CmsApiAction.async { ctx => + { + Ok( + Json.obj( + "token" -> encrypt( + env.config.cypherSecret, + "daikoku-cli-login", + ctx.tenant + ) + ) + ).future + } + } + + def redirectToLoginPage() = + DaikokuActionMaybeWithoutUser.async { ctx => + ctx.request.queryString.get("redirect").flatMap(_.headOption) match { + case Some(redirect: String) => + ctx.tenant.authProvider match { + case Otoroshi => FastFuture.successful(Redirect(redirect)) + case OAuth2 => + val authConfig = OAuth2Config + .fromJson(ctx.tenant.authProviderSettings) + .toOption + .get + val clientId = authConfig.clientId + val responseType = "code" + val scope = authConfig.scope // "openid profile email name" + val redirectUri = authConfig.callbackUrl + + FastFuture.successful( + Redirect( + s"${authConfig.loginUrl}?scope=${scope}&client_id=$clientId&response_type=$responseType&redirect_uri=$redirectUri" + ).addingToSession( + s"redirect" -> redirect + )(ctx.request) + ) + case _ => + FastFuture.successful(Redirect(s"/?redirect=$redirect")) + } + case None => + NotFound(Json.obj("error" -> "redirect param is missing")).future + } + } + +// def findById(id: String): Action[AnyContent] = +// DaikokuApiAction.async { ctx => +// val notDeleted: Boolean = +// ctx.request.queryString.get("notDeleted").exists(_.contains("true")) +// if (notDeleted) { +// entityStore(ctx.tenant, env.dataStore).findByIdNotDeleted(id).flatMap { +// case Some(entity) => FastFuture.successful(Ok(toJson(entity))) +// case None => +// Errors.craftResponseResult( +// s"$entityName not found", +// Results.NotFound, +// ctx.request, +// None, +// env +// ) +// } +// } else { +// entityStore(ctx.tenant, env.dataStore).findById(id).flatMap { +// case Some(entity) => FastFuture.successful(Ok(toJson(entity))) +// case None => +// Errors.craftResponseResult( +// s"$entityName not found", +// Results.NotFound, +// ctx.request, +// None, +// env +// ) +// } +// } +// } +// +// def createEntity(): Action[JsValue] = +// DaikokuApiAction.async(parse.json) { ctx => +// fromJson(ctx.request.body) match { +// case Left(e) => +// logger.error(s"Bad $entityName format", new RuntimeException(e)) +// Errors.craftResponseResult( +// s"Bad $entityName format", +// Results.BadRequest, +// ctx.request, +// None, +// env +// ) +// case Right(newEntity) => +// entityStore(ctx.tenant, env.dataStore) +// .findByIdNotDeleted(getId(newEntity).value) +// .flatMap { +// case Some(_) => +// AppError +// .EntityConflict("entity with same id already exists") +// .renderF() +// case None => +// validate(newEntity, UpdateOrCreate.Create) +// .map(entity => +// entityStore(ctx.tenant, env.dataStore) +// .save(entity) +// .map(_ => Created(toJson(entity))) +// ) +// .leftMap(_.renderF()) +// .merge +// .flatten +// } +// +// } +// } +// +// def updateEntity(id: String): Action[JsValue] = +// DaikokuApiAction.async(parse.json) { ctx => +// entityStore(ctx.tenant, env.dataStore).findById(id).flatMap { +// case None => +// Errors.craftResponseResult( +// s"Entity $entityName not found", +// Results.NotFound, +// ctx.request, +// None, +// env +// ) +// case Some(_) => +// fromJson(ctx.request.body) match { +// case Left(e) => +// logger.error(s"Bad $entityName format", new RuntimeException(e)) +// Errors.craftResponseResult( +// s"Bad $entityName format", +// Results.BadRequest, +// ctx.request, +// None, +// env +// ) +// case Right(newEntity) => +// validate(newEntity, UpdateOrCreate.Update) +// .map(entity => +// entityStore(ctx.tenant, env.dataStore) +// .save(entity) +// .map(_ => NoContent) +// ) +// .leftMap(_.renderF()) +// .merge +// .flatten +// } +// } +// } +// +// def patchEntity(id: String): Action[JsValue] = +// DaikokuApiAction.async(parse.json) { ctx => +// object JsonPatchHelpers { +// import diffson.jsonpatch._ +// import diffson.jsonpatch.lcsdiff.remembering.JsonDiffDiff +// import diffson.lcs._ +// import diffson.playJson.DiffsonProtocol._ +// import diffson.playJson._ +// +// private def patchResponse( +// patchJson: JsonPatch[JsValue], +// document: JsValue +// ): Either[AppError, JsValue] = { +// patchJson.apply(document) match { +// case JsSuccess(value, path) => Right(value) +// case JsError(errors) => +// logger.error(s"error during patch entity : $errors") +// val formattedErrors = errors.toVector.flatMap { +// case (JsPath(nodes), es) => +// es.map(e => e.message) +// } +// Left(AppError.EntityConflict(formattedErrors.mkString(","))) +// } +// } +// +// def patchJson( +// patchOps: JsValue, +// document: JsValue +// ): Either[AppError, JsValue] = { +// val patch = +// diffson.playJson.DiffsonProtocol.JsonPatchFormat.reads(patchOps).get +// patchResponse(patch, document) +// } +// +// def diffJson( +// sourceJson: JsValue, +// targetJson: JsValue +// ): Either[AppError, JsValue] = { +// implicit val lcs = new Patience[JsValue] +// val diff = diffson.diff(sourceJson, targetJson) +// patchResponse(diff, targetJson) +// } +// +// } +// +// val fu: Future[Option[CmsPage]] = +// if ( +// ctx.request.queryString +// .get("notDeleted") +// .exists(_.contains("true")) +// ) { +// entityStore(ctx.tenant, env.dataStore).findByIdNotDeleted(id) +// } else { +// entityStore(ctx.tenant, env.dataStore).findById(id) +// } +// +// def finalizePatch(patchedJson: JsValue): Future[Result] = { +// fromJson(patchedJson) match { +// case Left(e) => +// logger.error(s"Bad $entityName format", new RuntimeException(e)) +// Errors.craftResponseResult( +// s"Bad $entityName format", +// Results.BadRequest, +// ctx.request, +// None, +// env +// ) +// case Right(patchedEntity) => +// validate(patchedEntity, UpdateOrCreate.Update) +// .map(entity => +// entityStore(ctx.tenant, env.dataStore) +// .save(entity) +// .map(_ => NoContent) +// ) +// .leftMap(_.renderF()) +// .merge +// .flatten +// } +// } +// +// val value: Future[Result] = fu.flatMap { +// case None => +// Errors.craftResponseResult( +// s"Entity $entityName not found", +// Results.NotFound, +// ctx.request, +// None, +// env +// ) +// case Some(entity) => +// val currentJson = toJson(entity) +// ctx.request.body match { +// case JsArray(_) => +// val patchedJson = +// JsonPatchHelpers.patchJson(ctx.request.body, currentJson) +// patchedJson.fold( +// error => error.renderF(), +// json => finalizePatch(json) +// ) +// case JsObject(_) => +// val newJson = +// currentJson +// .as[JsObject] +// .deepMerge(ctx.request.body.as[JsObject]) +// fromJson(newJson) match { +// case Left(e) => +// logger.error( +// s"Bad $entityName format", +// new RuntimeException(e) +// ) +// Errors.craftResponseResult( +// s"Bad $entityName format", +// Results.BadRequest, +// ctx.request, +// None, +// env +// ) +// case Right(patchedEntity) => +// val patchedJson = +// JsonPatchHelpers.diffJson(newJson, toJson(patchedEntity)) +// patchedJson.fold( +// error => error.renderF(), +// json => finalizePatch(json) +// ) +// +// } +// +// case _ => +// FastFuture.successful( +// BadRequest( +// Json.obj("error" -> "[patch error] wrong patch format") +// ) +// ) +// } +// +// } +// value +// } +// +// def deleteEntity(id: String): Action[AnyContent] = +// DaikokuApiAction.async { ctx => +// if (ctx.request.queryString.get("logically").exists(_.contains("true"))) { +// entityStore(ctx.tenant, env.dataStore) +// .deleteByIdLogically(id) +// .map(_ => Ok(Json.obj("done" -> true))) +// } else { +// entityStore(ctx.tenant, env.dataStore) +// .deleteById(id) +// .map(_ => Ok(Json.obj("done" -> true))) +// } +// } +} + +class CmsApiSwaggerController(cc: ControllerComponents) + extends AbstractController(cc) { + + def swagger() = + Action { + Using( + scala.io.Source.fromResource("public/swaggers/cms-api-openapi.json") + ) { source => + source.mkString + } match { + case Failure(e) => + AppLogger.error(e.getMessage, e) + BadRequest(Json.obj("error" -> e.getMessage)) + case Success(value) => + Ok(Json.parse(value)).withHeaders( + "Access-Control-Allow-Origin" -> "*" + ) + } + } +} diff --git a/daikoku/app/controllers/HomeController.scala b/daikoku/app/controllers/HomeController.scala index 056d73a29..0bb3b440f 100644 --- a/daikoku/app/controllers/HomeController.scala +++ b/daikoku/app/controllers/HomeController.scala @@ -12,7 +12,7 @@ import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.domain.json.{CmsFileFormat, CmsPageFormat, CmsRequestRenderingFormat} import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger -import fr.maif.otoroshi.daikoku.utils.{Errors, IdGenerator, diff_match_patch} +import fr.maif.otoroshi.daikoku.utils.Errors import org.apache.pekko.http.scaladsl.util.FastFuture import org.joda.time.DateTime import play.api.i18n.{I18nSupport, MessagesApi} @@ -427,263 +427,17 @@ class HomeController( } } - def session(userId: String) = - DaikokuAction.async { ctx => - DaikokuAdminOrSelf(AuditTrailEvent("@{user.name} get session"))( - UserId(userId), - ctx - ) { - val token = - ctx.request.cookies.get("daikoku-session").map(_.value).getOrElse("") - FastFuture.successful(Ok(Json.obj("token" -> token))) - } - } - - def sync() = - DaikokuAction.async(parse.json) { ctx => - TenantAdminOnly( - AuditTrailEvent("@{user.name} sync cms project") - )(ctx.tenant.id.value, ctx) { (tenant, _) => - { - val body = ctx.request.body - - println(body) - - Future - .sequence( - body - .as(Reads.seq(CmsFileFormat.reads)) - .map(page => { - env.dataStore.cmsRepo - .forTenant(tenant) - .save(page.toCmsPage(ctx.tenant.id)) - }) - ) - .map(_ => NoContent) - .recover { - case e: Throwable => - BadRequest(Json.obj("error" -> e.getMessage)) - } - } - } - } - - def cmsDiffById(id: String, diffId: String) = - DaikokuAction.async { ctx => - TenantAdminOnly(AuditTrailEvent("@{user.name} has get a cms diff"))( - ctx.tenant.id.value, - ctx - ) { (tenant, _) => - { - val diffMatchPatch = new diff_match_patch() - - env.dataStore.cmsRepo - .forTenant(tenant) - .findById(id) - .map { - case None => NotFound(Json.obj("error" -> "cms page not found")) - case Some(page) => - val historySeq = buildCmsPageFromPatches(page.history, diffId) - val diffs = diffMatchPatch.diff_main( - page.draft, - historySeq - ) - Ok( - Json.obj( - "html" -> (if ( - ctx.request - .getQueryString("showDiffs") - .exists(_.toBoolean) - ) - diffMatchPatch.diff_prettyHtml(diffs) - else historySeq), - "hasDiff" -> !diffs.isEmpty - ) - ) - } - } - } - } - - def restoreDiff(id: String, diffId: String) = - DaikokuAction.async { ctx => - TenantAdminOnly( - AuditTrailEvent( - "@{user.name} has restore the cms page @{pageName} with revision of @{diffDate}" - ) - )(ctx.tenant.id.value, ctx) { (tenant, _) => - { - env.dataStore.cmsRepo - .forTenant(tenant) - .findById(id) - .flatMap { - case None => - FastFuture.successful( - NotFound(Json.obj("error" -> "cms page not found")) - ) - case Some(page) => - ctx.setCtxValue("pageName", page.name) - ctx.setCtxValue( - "diffDate", - page.history - .find(_.id == diffId) - .map(_.date) - .getOrElse("unknown date") - ) - - val newContentPage = - buildCmsPageFromPatches(page.history, diffId) - val history = diff(newContentPage, page.draft, ctx.user.id) - - env.dataStore.cmsRepo - .forTenant(tenant) - .save( - page.copy( - draft = newContentPage, - history = (page.history :+ history).take( - tenant.style.map(_.cmsHistoryLength).getOrElse(10) + 1 - ) - ) - ) - .map(_ => Ok(Json.obj("restored" -> true))) - } - } - } - } - - def createCmsPageWithName(name: String) = - DaikokuAction.async { ctx => - TenantAdminOnly( - AuditTrailEvent("@{user.name} has created a cms page with name") - )(ctx.tenant.id.value, ctx) { (tenant, _) => - { - val page = CmsPage( - id = CmsPageId(IdGenerator.token(32)), - tenant = tenant.id, - visible = true, - authenticated = false, - name = name, - forwardRef = None, - tags = List(), - metadata = Map(), - draft = "", - contentType = "text/html", - body = "", - path = Some("/" + IdGenerator.token(32)) - ) - env.dataStore.cmsRepo - .forTenant(tenant) - .save(page) - .map { - case true => Created(page.asJson) - case false => - BadRequest(Json.obj("error" -> "Error when creating cms page")) - } - } - } - } - - def diff(a: String, b: String, userId: UserId): CmsHistory = { - val patchMatch = new diff_match_patch() - val diff = patchMatch.patch_toText(patchMatch.patch_make(a, b)) - CmsHistory( - id = IdGenerator.token(32), - date = DateTime.now(), - diff = diff, - user = userId - ) - } - - private def buildCmsPageFromPatches( - history: Seq[CmsHistory], - diffId: String - ): String = { - var diffReached = false - val items = history.flatMap(item => { - if (item.id == diffId) { - diffReached = true - Some(item) - } else if (!diffReached) - Some(item) - else - None - }) - val diffMatchPatch = new diff_match_patch() - items.foldLeft("") { - case (text, current) => - diffMatchPatch.patch_apply( - new util.LinkedList(diffMatchPatch.patch_fromText(current.diff)), - text - ) - } - } - - def createCmsPage() = - DaikokuAction.async(parse.json) { ctx => - TenantAdminOnly(AuditTrailEvent("@{user.name} has created a cms page"))( - ctx.tenant.id.value, - ctx - ) { (tenant, _) => - { - val body = ctx.request.body.as[JsObject] - - val cmsPage = body ++ - Json.obj( - "_id" -> JsString( - (body \ "id") - .asOpt[String] - .getOrElse( - (body \ "_id") - .asOpt[String] - .getOrElse(IdGenerator.token(32)) - ) - ) - ) ++ - Json.obj("_tenant" -> tenant.id.value) - - json.CmsPageFormat.reads(cmsPage) match { - case JsSuccess(page, _) => - env.dataStore.cmsRepo - .forTenant(tenant) - .findByIdOrHrId(page.id.value) - .map { - case Some(cms) => - val d = diff(cms.draft, page.draft, ctx.user.id) - if (d.diff.nonEmpty) - page.copy(history = cms.history :+ d) - else - page.copy(history = cms.history) - case None => - val d = diff("", page.draft, ctx.user.id) - if (d.diff.nonEmpty) - page.copy(history = Seq(d)) - else - page - } - .flatMap(page => { - env.dataStore.cmsRepo - .forTenant(tenant) - .save( - page.copy(history = - page.history.takeRight( - tenant.style.map(_.cmsHistoryLength).getOrElse(10) + 1 - ) - ) - ) - .map { - case true => Created(Json.obj("created" -> true)) - case false => - BadRequest( - Json.obj("error" -> "Error when creating cms page") - ) - } - }) - case e: JsError => - FastFuture.successful(BadRequest(JsError.toJson(e))) - } - } - } - } +// def session(userId: String) = +// DaikokuAction.async { ctx => +// DaikokuAdminOrSelf(AuditTrailEvent("@{user.name} get session"))( +// UserId(userId), +// ctx +// ) { +// val token = +// ctx.request.cookies.get("daikoku-session").map(_.value).getOrElse("") +// FastFuture.successful(Ok(Json.obj("token" -> token))) +// } +// } def deleteCmsPage(id: String) = DaikokuAction.async { ctx => @@ -712,70 +466,24 @@ class HomeController( "text/xml" -> "xml" ) - def summary() = - DaikokuAction.async { ctx => - TenantAdminOnly( - AuditTrailEvent("@{user.name} has download the cms summary") - )(ctx.tenant.id.value, ctx) { (tenant, _) => - env.dataStore.cmsRepo - .forTenant(tenant) - .findAllNotDeleted() - .map(pages => { - val summary = pages.foldLeft(Json.arr()) { (acc, page) => - acc ++ Json - .arr(page.asJson.as[JsObject] - "draft" - "history") - } - - Ok(summary) - }) - } - } - - def download() = - DaikokuAction.async { ctx => - TenantAdminOnly( - AuditTrailEvent("@{user.name} has download all files of the cms") - )(ctx.tenant.id.value, ctx) { (tenant, _) => - env.dataStore.cmsRepo - .forTenant(tenant) - .findAllNotDeleted() - .map(pages => { - val outZip = new File(s"/tmp/${System.currentTimeMillis()}.zip") - val out = new ZipOutputStream(new FileOutputStream(outZip)) - - pages.foreach(page => { - val sb = new StringBuilder() - sb.append(page.body) - val data = sb.toString().getBytes() - - val e = new ZipEntry( - s"${page.name}.${contentTypeToExtension.getOrElse(page.contentType, ".txt")}" - ) - out.putNextEntry(e) - - out.write(data, 0, data.length) - }) - - val summary = pages.foldLeft(Json.arr()) { (acc, page) => - acc ++ Json - .arr(page.asJson.as[JsObject] - "body" - "draft" - "history") - } - - val sb = new StringBuilder() - sb.append(Json.stringify(summary)) - val data = sb.toString().getBytes() - - val e = new ZipEntry("summary.json") - out.putNextEntry(e) - out.write(data, 0, data.length) - - out.closeEntry() - out.close() - - Ok.sendFile(outZip) - }) - } - } +// def summary() = +// DaikokuAction.async { ctx => +// TenantAdminOnly( +// AuditTrailEvent("@{user.name} has download the cms summary") +// )(ctx.tenant.id.value, ctx) { (tenant, _) => +// env.dataStore.cmsRepo +// .forTenant(tenant) +// .findAllNotDeleted() +// .map(pages => { +// val summary = pages.foldLeft(Json.arr()) { (acc, page) => +// acc ++ Json +// .arr(page.asJson.as[JsObject] - "draft" - "history") +// } +// +// Ok(summary) +// }) +// } +// } def importFromZip() = DaikokuAction.async(parse.multipartFormData) { ctx => @@ -840,7 +548,6 @@ class HomeController( case Some((_, value)) => value case None => page.draft } - val d = diff("", content, ctx.user.id) env.dataStore.cmsRepo .forTenant(ctx.tenant) .save( @@ -848,7 +555,6 @@ class HomeController( draft = content, body = content, tenant = ctx.tenant.id, - history = if (d.diff.nonEmpty) Seq(d) else Seq.empty ) ) }) diff --git a/daikoku/app/controllers/LoginController.scala b/daikoku/app/controllers/LoginController.scala index f25b5a4cb..983f2e325 100644 --- a/daikoku/app/controllers/LoginController.scala +++ b/daikoku/app/controllers/LoginController.scala @@ -1,6 +1,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.audit.{AuditTrailEvent, AuthorizationLevel} @@ -41,58 +42,6 @@ class LoginController( implicit val ev: Env = env implicit val tr: Translator = translator - def redirectToLoginPage() = - DaikokuTenantAction.async { ctx => - { - val user = ctx.request.attrs.get(IdentityAttrs.UserKey) - val redirect = - ctx.request.queryString.get("redirect").flatMap(_.headOption) - - val hasUserAndRedirect = - redirect.isDefined && user.exists(u => !u.isGuest) - - println(hasUserAndRedirect, ctx.tenant.cmsRedirections) - - if ( - hasUserAndRedirect && (ctx.tenant.cmsRedirections.isEmpty || !ctx.tenant.cmsRedirections - .contains(redirect.get)) - ) { - FastFuture.successful( - BadRequest(Json.obj("error" -> "application not authorized")) - ) - } else { - if (hasUserAndRedirect) { - FastFuture.successful(Redirect(redirect.get)) - } else { - ctx.tenant.authProvider match { - case Otoroshi => FastFuture.successful(Redirect("/")) - case OAuth2 => - val authConfig = OAuth2Config - .fromJson(ctx.tenant.authProviderSettings) - .toOption - .get - val clientId = authConfig.clientId - val responseType = "code" - val scope = authConfig.scope // "openid profile email name" - val redirectUri = authConfig.callbackUrl - - FastFuture.successful( - Redirect( - s"${authConfig.loginUrl}?scope=${scope}&client_id=$clientId&response_type=$responseType&redirect_uri=$redirectUri" - ).addingToSession( - s"redirect" -> ctx.request - .getQueryString("redirect") - .getOrElse("/") - )(ctx.request) - ) - case _ => - assets.at("index.html").apply(ctx.request) - } - } - } - } - } - def loginContext(provider: String) = DaikokuActionMaybeWithoutUser { _ => Ok( @@ -225,11 +174,19 @@ class LoginController( AuthorizationLevel.AuthorizedSelf ) - val redirectUri = request.session + var redirectUri = request.session .get("redirect") .getOrElse(request.getQueryString("redirect").getOrElse("/")) - Redirect(if (redirectUri.startsWith("/api/")) "/" else redirectUri) + redirectUri = if (redirectUri.startsWith("/api/")) "/" else redirectUri + + try { + redirectUri = new String(Base64.getUrlDecoder.decode(redirectUri), Charsets.UTF_8) + } catch { + case _: Throwable => + } + + Redirect(redirectUri) .withSession("sessionId" -> session.sessionId.value) .removingFromSession("redirect")(request) } diff --git a/daikoku/app/controllers/TranslationController.scala b/daikoku/app/controllers/TranslationController.scala index ba035a44a..dbcb20be7 100644 --- a/daikoku/app/controllers/TranslationController.scala +++ b/daikoku/app/controllers/TranslationController.scala @@ -10,6 +10,7 @@ import fr.maif.otoroshi.daikoku.ctrls.authorizations.async._ import fr.maif.otoroshi.daikoku.domain.json._ import fr.maif.otoroshi.daikoku.domain.{DatastoreId, IntlTranslation, Translation} import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.services.TranslationsService import fr.maif.otoroshi.daikoku.utils.{IdGenerator, Translator} import org.joda.time.DateTime import play.api.i18n.I18nSupport @@ -23,7 +24,8 @@ class TranslationController( DaikokuActionMaybeWithoutUser: DaikokuActionMaybeWithoutUser, env: Env, cc: ControllerComponents, - translator: Translator + translator: Translator, + translationsService: TranslationsService ) extends AbstractController(cc) with I18nSupport { @@ -49,61 +51,7 @@ class TranslationController( TenantAdminOnly( AuditTrailEvent(s"@{user.name} has reset translations - @{tenant._id}") )(ctx.tenant.id.value, ctx) { (_, _) => - env.dataStore.translationRepo - .forTenant(ctx.tenant.id) - .find( - Json.obj( - "key" -> Json.obj( - "$regex" -> s".*${domain.getOrElse("mail")}", - "$options" -> "-i" - ) - ) - ) - .map(translations => { - val defaultTranslations = messagesApi.messages - .map(v => - ( - v._1, - v._2.filter(k => k._1.startsWith(domain.getOrElse("mail"))) - ) - ) - .flatMap { v => - v._2 - .map { - case (key, value) => - Translation( - id = DatastoreId(IdGenerator.token(32)), - tenant = ctx.tenant.id, - language = v._1, - key = key, - value = value - ) - } - .filter(t => languages.contains(t.language)) - } - - Ok( - Json.obj( - "translations" -> defaultTranslations - .map { translation => - translations.find(t => - t.key == translation.key && t.language == translation.language - ) match { - case None => translation - case Some(t) => t - } - } - .groupBy(_.key) - .map(v => IntlTranslationFormat.writes(IntlTranslation( - id = v._1, - translations = v._2.toSeq, - content = defaultTranslations - .find(p => p.key == v._1) - .map(_.value) - .getOrElse("") - ))) - )) - }) + translationsService.getMailTranslations(ctx, domain, messagesApi, supportedLangs) } } diff --git a/daikoku/app/daikoku.scala b/daikoku/app/daikoku.scala index 0d9c3a1e5..862268dea 100644 --- a/daikoku/app/daikoku.scala +++ b/daikoku/app/daikoku.scala @@ -4,36 +4,20 @@ import org.apache.pekko.http.scaladsl.util.FastFuture import org.apache.pekko.stream.Materializer import com.softwaremill.macwire._ import controllers.{Assets, AssetsComponents} -import fr.maif.otoroshi.daikoku.actions.{ - DaikokuAction, - DaikokuActionMaybeWithGuest, - DaikokuActionMaybeWithoutUser, - DaikokuTenantAction -} +import fr.maif.otoroshi.daikoku.actions.{CmsApiAction, DaikokuAction, DaikokuActionMaybeWithGuest, DaikokuActionMaybeWithoutUser, DaikokuTenantAction} import fr.maif.otoroshi.daikoku.ctrls._ import fr.maif.otoroshi.daikoku.env._ import fr.maif.otoroshi.daikoku.modules.DaikokuComponentsInstances +import fr.maif.otoroshi.daikoku.services.TranslationsService import fr.maif.otoroshi.daikoku.utils.RequestImplicits._ import fr.maif.otoroshi.daikoku.utils.admin._ -import fr.maif.otoroshi.daikoku.utils.{ - ApiService, - DeletionService, - Errors, - OtoroshiClient, - Translator -} +import fr.maif.otoroshi.daikoku.utils.{ApiService, DeletionService, Errors, OtoroshiClient, Translator} import io.vertx.core.Vertx import io.vertx.core.buffer.Buffer import io.vertx.core.net.{PemKeyCertOptions, PemTrustOptions} import io.vertx.pgclient.{PgConnectOptions, PgPool, SslMode} import io.vertx.sqlclient.PoolOptions -import jobs.{ - AnonymousReportingJob, - ApiKeyStatsJob, - AuditTrailPurgeJob, - OtoroshiVerifierJob, - QueueJob -} +import jobs.{AnonymousReportingJob, ApiKeyStatsJob, AuditTrailPurgeJob, OtoroshiVerifierJob, QueueJob} import play.api.ApplicationLoader.Context import play.api._ import play.api.http.{DefaultHttpFilters, HttpErrorHandler} @@ -77,6 +61,7 @@ package object modules { lazy val paymentClient = wire[PaymentClient] lazy val apiService = wire[ApiService] + lazy val translationsService = wire[TranslationsService] lazy val deletionService = wire[DeletionService] lazy val translator = wire[Translator] @@ -95,6 +80,7 @@ package object modules { val daikokuTenantActionMaybeWithGuest = wire[DaikokuActionMaybeWithGuest] val daikokuActionMaybeWithoutUser = wire[DaikokuActionMaybeWithoutUser] val daikokuApiAction = wire[DaikokuApiAction] + val cmsApiAction = wire[CmsApiAction] val daikokuApiActionWithoutTenant = wire[DaikokuApiActionWithoutTenant] lazy val homeController = wire[HomeController] @@ -146,6 +132,8 @@ package object modules { lazy val subscriptionDemandsAdminApiController = wire[SubscriptionDemandsAdminApiController] lazy val graphQLController = wire[GraphQLController] + lazy val cmsApiController = wire[CmsApiController] + lazy val cmsApiSwaggerController = wire[CmsApiSwaggerController] override lazy val assets: Assets = wire[Assets] lazy val router: Router = { diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 49630ab84..1ad0c8401 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -3845,12 +3845,6 @@ object SchemaDefinition { "lastPublishedDate", OptionType(LongType), resolve = _.value.lastPublishedDate.map(p => p.getMillis) - ), - Field( - "history", - ListType(CmsHistoryType), - resolve = _.value.history - .sortBy(_.date.toInstant.getMillis)(Ordering[Long].reverse) ) ) ) diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 9a70069f5..cdbafa654 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -2211,9 +2211,6 @@ object json { environments = (json \ "environments") .asOpt[Set[String]] .getOrElse(Set.empty), - cmsRedirections = (json \ "cmsRedirections") - .asOpt[Set[String]] - .getOrElse(Set.empty) ) ) } recover { @@ -2285,10 +2282,7 @@ object json { "thirdPartyPaymentSettings" -> SeqThirdPartyPaymentSettingsFormat .writes(o.thirdPartyPaymentSettings), "display" -> TenantDisplayFormat.writes(o.display), - "environments" -> JsArray(o.environments.map(JsString.apply).toSeq), - "cmsRedirections" -> JsArray( - o.cmsRedirections.map(JsString.apply).toSeq - ) + "environments" -> JsArray(o.environments.map(JsString.apply).toSeq) ) } val AuditTrailConfigFormat = new Format[AuditTrailConfig] { @@ -4623,8 +4617,7 @@ object json { "draft" -> o.draft, "path" -> o.path.map(JsString.apply).getOrElse(JsNull).as[JsValue], "exact" -> o.exact, - "lastPublishedDate" -> o.lastPublishedDate.map(DateTimeFormat.writes), - "history" -> SeqCmsHistoryFormat.writes(o.history) + "lastPublishedDate" -> o.lastPublishedDate.map(DateTimeFormat.writes) ) override def reads(json: JsValue): JsResult[CmsPage] = Try { @@ -4652,9 +4645,6 @@ object json { exact = (json \ "exact").asOpt[Boolean].getOrElse(false), lastPublishedDate = (json \ "lastPublishedDate").asOpt[DateTime](DateTimeFormat.reads), - history = (json \ "history") - .asOpt(SeqCmsHistoryFormat) - .getOrElse(Seq.empty) ) } match { case Failure(exception) => JsError(exception.getMessage) @@ -4777,8 +4767,6 @@ object json { Reads.set(OtoroshiServiceGroupIdFormat), Writes.set(OtoroshiServiceGroupIdFormat) ) - val SeqCmsHistoryFormat = - Format(Reads.seq(CmsHistoryFormat), Writes.seq(CmsHistoryFormat)) val SeqApiDocumentationDetailPageFormat : Format[Seq[ApiDocumentationDetailPage]] = Format( diff --git a/daikoku/app/domain/tenantEntities.scala b/daikoku/app/domain/tenantEntities.scala index 9e67b3a6a..502a6c7ce 100644 --- a/daikoku/app/domain/tenantEntities.scala +++ b/daikoku/app/domain/tenantEntities.scala @@ -386,8 +386,7 @@ case class Tenant( robotTxt: Option[String] = None, thirdPartyPaymentSettings: Seq[ThirdPartyPaymentSettings] = Seq.empty, display: TenantDisplay = TenantDisplay.Default, - environments: Set[String] = Set.empty, - cmsRedirections: Set[String] = Set.empty + environments: Set[String] = Set.empty ) extends CanJson[Tenant] { override def asJson: JsValue = json.TenantFormat.writes(this) @@ -453,7 +452,6 @@ case class Tenant( "display" -> display.name, "environments" -> JsArray(environments.map(JsString.apply).toSeq), "loginProvider" -> authProvider.name, - "cmsRedirections" -> JsArray(cmsRedirections.map(JsString.apply).toSeq), "colorTheme" -> style.map(_.colorTheme).map(JsString.apply).getOrElse(JsNull).as[JsValue], "css" -> style.map(_.css).map(JsString.apply).getOrElse(JsNull).as[JsValue], "cssUrl" -> style.flatMap(_.cssUrl).map(JsString.apply).getOrElse(JsNull).as[JsValue], @@ -671,8 +669,7 @@ case class CmsPage( draft: String, path: Option[String] = None, exact: Boolean = false, - lastPublishedDate: Option[DateTime] = None, - history: Seq[CmsHistory] = Seq.empty + lastPublishedDate: Option[DateTime] = None ) extends CanJson[CmsPage] { override def asJson: JsValue = json.CmsPageFormat.writes(this) diff --git a/daikoku/app/env/env.scala b/daikoku/app/env/env.scala index c79be4c83..586d798bb 100644 --- a/daikoku/app/env/env.scala +++ b/daikoku/app/env/env.scala @@ -145,6 +145,31 @@ object AdminApiConfig { } } +sealed trait CmsApiConfig + +case class LocalCmsApiConfig(key: String) extends CmsApiConfig + +case class OtoroshiCmsApiConfig(claimsHeaderName: String, algo: Algorithm) + extends CmsApiConfig + +object CmsApiConfig { + def apply(config: Configuration): CmsApiConfig = { + config.getOptional[String]("daikoku.cms.api.type") match { + case Some("local") => + LocalCmsApiConfig(config.getOptional[String]("daikoku.cms.api.key").get) + case Some("otoroshi") => + OtoroshiCmsApiConfig( + config.getOptional[String]("daikoku.cms.api.headerName").get, + Algorithm.HMAC512( + config.getOptional[String]("daikoku.cms.api.headerSecret").get + ) + ) + case _ => + LocalCmsApiConfig(config.getOptional[String]("daikoku.api.key").get) + } + } +} + class Config(val underlying: Configuration) { lazy val port: Int = underlying @@ -250,6 +275,8 @@ class Config(val underlying: Configuration) { lazy val adminApiConfig: AdminApiConfig = AdminApiConfig(underlying) + lazy val cmsApiConfig: CmsApiConfig = CmsApiConfig(underlying) + lazy val anonymousReportingUrl: String = underlying.get[String]("daikoku.anonymous-reporting.url") lazy val anonymousReportingTimeout: Int = @@ -430,6 +457,8 @@ class DaikokuEnv( val administrationTeamId = TeamId("administration") val adminApiDefaultTenantId = ApiId(s"admin-api-tenant-${Tenant.Default.value}") + val cmsApiDefaultTenantId = + ApiId(s"cms-api-tenant-${Tenant.Default.value}") val defaultAdminTeam = Team( id = TeamId(IdGenerator.token), tenant = Tenant.Default, @@ -480,6 +509,45 @@ class DaikokuEnv( authorizedTeams = Seq.empty, state = ApiState.Published ) + + val cmsApiDefaultPlan = FreeWithoutQuotas( + id = UsagePlanId(IdGenerator.token), + tenant = Tenant.Default, + billingDuration = BillingDuration(1, BillingTimeUnit.Month), + currency = Currency("EUR"), + customName = Some("admin"), + customDescription = None, + otoroshiTarget = None, + allowMultipleKeys = Some(true), + autoRotation = None, + subscriptionProcess = Seq.empty, + integrationProcess = IntegrationProcess.ApiKey + ) + + val cmsApiDefaultTenant = Api( + id = cmsApiDefaultTenantId, + tenant = Tenant.Default, + team = defaultAdminTeam.id, + name = s"cms-api-tenant-${Tenant.Default.value}", + lastUpdate = DateTime.now(), + smallDescription = "cms api", + description = "cms api", + currentVersion = Version("1.0.0"), + documentation = ApiDocumentation( + id = ApiDocumentationId(IdGenerator.token(32)), + tenant = Tenant.Default, + pages = Seq.empty[ApiDocumentationDetailPage], + lastModificationAt = DateTime.now() + ), + swagger = + Some(SwaggerAccess(url = "/cms-api/swagger.json".some)), + possibleUsagePlans = Seq(cmsApiDefaultPlan.id), + visibility = ApiVisibility.AdminOnly, + defaultUsagePlan = cmsApiDefaultPlan.id.some, + authorizedTeams = Seq.empty, + state = ApiState.Published + ) + val tenant = Tenant( id = Tenant.Default, name = "Daikoku Default Tenant", @@ -536,6 +604,18 @@ class DaikokuEnv( dataStore.apiRepo .forTenant(tenant.id) .save(adminApiDefaultTenant) + _ <- + dataStore.usagePlanRepo + .forTenant(tenant.id) + .save(adminApiDefaultPlan) + _ <- + dataStore.apiRepo + .forTenant(tenant.id) + .save(cmsApiDefaultTenant) + _ <- + dataStore.usagePlanRepo + .forTenant(tenant.id) + .save(cmsApiDefaultPlan) _ <- dataStore.userRepo.save(user) } yield () diff --git a/daikoku/app/env/evolutions.scala b/daikoku/app/env/evolutions.scala index b483308ab..4e52a427d 100644 --- a/daikoku/app/env/evolutions.scala +++ b/daikoku/app/env/evolutions.scala @@ -6,17 +6,9 @@ import org.apache.pekko.stream.Materializer import org.apache.pekko.stream.scaladsl.{Sink, Source} 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 @@ -1093,6 +1085,95 @@ object evolution_1634 extends EvolutionScript { } } +object evolution_1750 extends EvolutionScript { + override def version: String = "17.5.0" + + override def script: ( + Option[DatastoreId], + DataStore, + Materializer, + ExecutionContext, + OtoroshiClient + ) => Future[Done] = + ( + _: Option[DatastoreId], + dataStore: DataStore, + mat: Materializer, + ec: ExecutionContext, + _: OtoroshiClient + ) => { + AppLogger.info( + s"Begin evolution $version - create cms api" + ) + + implicit val executionContext: ExecutionContext = ec + + val cmsApiDefaultPlan = FreeWithoutQuotas( + id = UsagePlanId(IdGenerator.token), + tenant = Tenant.Default, + billingDuration = BillingDuration(1, BillingTimeUnit.Month), + currency = Currency("EUR"), + customName = Some("admin"), + customDescription = None, + otoroshiTarget = None, + allowMultipleKeys = Some(true), + autoRotation = None, + subscriptionProcess = Seq.empty, + integrationProcess = IntegrationProcess.ApiKey + ) + + val cmsApiDefaultTenantId = + ApiId(s"cms-api-tenant-${Tenant.Default.value}") + + for { + tenants <-dataStore.tenantRepo.findAll() + _ <- Future.sequence(tenants.map(tenant => dataStore.teamRepo + .forTenant(tenant) + .findOne(Json.obj("type" -> TeamType.Admin.name)) + .flatMap(team => { + if(team.isDefined) { + val cmsApiDefaultTenant = Api( + id = cmsApiDefaultTenantId, + tenant = Tenant.Default, + team = team.get.id, + name = s"cms-api-tenant-${Tenant.Default.value}", + lastUpdate = DateTime.now(), + smallDescription = "cms api", + description = "cms api", + currentVersion = Version("1.0.0"), + documentation = ApiDocumentation( + id = ApiDocumentationId(IdGenerator.token(32)), + tenant = Tenant.Default, + pages = Seq.empty[ApiDocumentationDetailPage], + lastModificationAt = DateTime.now() + ), + swagger = + Some(SwaggerAccess(url = "/cms-api/swagger.json".some)), + possibleUsagePlans = Seq(cmsApiDefaultPlan.id), + visibility = ApiVisibility.AdminOnly, + defaultUsagePlan = cmsApiDefaultPlan.id.some, + authorizedTeams = Seq.empty, + state = ApiState.Published + ) + + Future.sequence(Seq( + dataStore.apiRepo + .forTenant(tenant.id) + .save(cmsApiDefaultTenant), + dataStore.usagePlanRepo + .forTenant(tenant.id) + .save(cmsApiDefaultPlan) + )) + } else { + Future.successful(()) + } + }))) + } yield { + Done + } + } +} + object evolutions { val list: List[EvolutionScript] = List( @@ -1109,7 +1190,8 @@ object evolutions { evolution_1613, evolution_1613_b, evolution_1630, - evolution_1634 + evolution_1634, + evolution_1750 ) def run( dataStore: DataStore, diff --git a/daikoku/app/services/ApisService.scala b/daikoku/app/services/ApisService.scala new file mode 100644 index 000000000..361cb2baa --- /dev/null +++ b/daikoku/app/services/ApisService.scala @@ -0,0 +1,17 @@ +package services + +import fr.maif.otoroshi.daikoku.actions.ApiActionContext +import fr.maif.otoroshi.daikoku.domain.json.IntlTranslationFormat +import fr.maif.otoroshi.daikoku.domain.{DatastoreId, IntlTranslation, Translation} +import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.utils.IdGenerator +import play.api.i18n.Langs +import play.api.libs.json.Json +import play.api.mvc.Results._ + +import scala.concurrent.ExecutionContext + +class ApisService { + + +} diff --git a/daikoku/app/services/TranslationsService.scala b/daikoku/app/services/TranslationsService.scala new file mode 100644 index 000000000..7f98a2f4d --- /dev/null +++ b/daikoku/app/services/TranslationsService.scala @@ -0,0 +1,94 @@ +package fr.maif.otoroshi.daikoku.services + +import fr.maif.otoroshi.daikoku.actions.{ApiActionContext} +import fr.maif.otoroshi.daikoku.domain.json.IntlTranslationFormat +import fr.maif.otoroshi.daikoku.domain.{ + DatastoreId, + IntlTranslation, + Translation +} +import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.utils.IdGenerator +import play.api.i18n.Langs +import play.api.libs.json.Json +import play.api.mvc.Results._ + +import scala.concurrent.ExecutionContext + +class TranslationsService { + + def getMailTranslations[T]( + ctx: ApiActionContext[T], + domain: Option[String], + messagesApi: play.api.i18n.MessagesApi, + supportedLangs: Langs + )(implicit + env: Env + ) = { + + implicit val ec: ExecutionContext = env.defaultExecutionContext + implicit val languages: Seq[String] = + supportedLangs.availables.map(_.language) + + env.dataStore.translationRepo + .forTenant(ctx.tenant.id) + .find( + Json.obj( + "key" -> Json.obj( + "$regex" -> s".*${domain.getOrElse("mail")}", + "$options" -> "-i" + ) + ) + ) + .map(translations => { + val defaultTranslations = messagesApi.messages + .map(v => + ( + v._1, + v._2.filter(k => k._1.startsWith(domain.getOrElse("mail"))) + ) + ) + .flatMap { v => + v._2 + .map { + case (key, value) => + Translation( + id = DatastoreId(IdGenerator.token(32)), + tenant = ctx.tenant.id, + language = v._1, + key = key, + value = value + ) + } + .filter(t => languages.contains(t.language)) + } + + Ok( + Json.obj( + "translations" -> defaultTranslations + .map { translation => + translations.find(t => + t.key == translation.key && t.language == translation.language + ) match { + case None => translation + case Some(t) => t + } + } + .groupBy(_.key) + .map(v => + IntlTranslationFormat.writes( + IntlTranslation( + id = v._1, + translations = v._2.toSeq, + content = defaultTranslations + .find(p => p.key == v._1) + .map(_.value) + .getOrElse("") + ) + ) + ) + ) + ) + }) + } +} diff --git a/daikoku/app/utils/ApiService.scala b/daikoku/app/utils/ApiService.scala index 4342a4222..356915b29 100644 --- a/daikoku/app/utils/ApiService.scala +++ b/daikoku/app/utils/ApiService.scala @@ -9,10 +9,12 @@ import cats.data.{EitherT, OptionT} import cats.implicits.catsSyntaxOptionId import controllers.AppError import controllers.AppError._ +import fr.maif.otoroshi.daikoku.actions.ApiActionContext import fr.maif.otoroshi.daikoku.ctrls.PaymentClient import fr.maif.otoroshi.daikoku.domain.TeamPermission.Administrator import fr.maif.otoroshi.daikoku.domain.UsagePlan._ import fr.maif.otoroshi.daikoku.domain._ +import fr.maif.otoroshi.daikoku.domain.json.SeqApiFormat import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.utils.Cypher.encrypt @@ -2227,4 +2229,27 @@ class ApiService( } yield Ok(Json.obj("creation" -> "refused")) } + def getApis[T](ctx: ApiActionContext[T]) = env.dataStore.apiRepo.forTenant(ctx.tenant) + .findAll() + .map(apis => { + val fields: Seq[String] = ctx.request.getQueryString("fields").map(_.split(",").toSeq).getOrElse(Seq.empty[String]) + val hasFields = fields.nonEmpty + if (hasFields) { + Ok(JsArray(apis.map(api => { + val jsonAPI = api.asJson + val content = jsonAPI match { + case arr @ JsArray(_) => + JsArray(arr.value.map { item => + JsonOperationsHelper.filterJson(item.as[JsObject], fields) + }) + case obj @ JsObject(_) => JsonOperationsHelper.filterJson(obj, fields) + case _ => jsonAPI + } + + content + }))) + } else { + Ok(SeqApiFormat.writes(apis)) + } + }) } diff --git a/daikoku/conf/routes b/daikoku/conf/routes index 5de20949e..4af811b8d 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -6,17 +6,28 @@ GET /_/*path fr.maif.otoroshi.daikoku POST /_*path fr.maif.otoroshi.daikoku.ctrls.HomeController.renderCmsPageFromBody(path) GET /cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.cmsPageById(id) POST /cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.advancedRenderCmsPageById(id) -GET /api/cms/pages/:id/diffs/:diffId fr.maif.otoroshi.daikoku.ctrls.HomeController.cmsDiffById(id, diffId) -POST /api/cms/pages/:id/diffs/:diffId fr.maif.otoroshi.daikoku.ctrls.HomeController.restoreDiff(id, diffId) -POST /api/cms/download fr.maif.otoroshi.daikoku.ctrls.HomeController.download() -GET /api/cms fr.maif.otoroshi.daikoku.ctrls.HomeController.summary() POST /api/cms/import fr.maif.otoroshi.daikoku.ctrls.HomeController.importFromZip() -POST /api/cms/pages/:pageName fr.maif.otoroshi.daikoku.ctrls.HomeController.createCmsPageWithName(pageName) -POST /api/cms/pages fr.maif.otoroshi.daikoku.ctrls.HomeController.createCmsPage() -DELETE /api/cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.deleteCmsPage(id) -GET /api/cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.getCmsPage(id) -GET /api/users/:userId/session fr.maif.otoroshi.daikoku.ctrls.HomeController.session(userId) -POST /api/cms/sync fr.maif.otoroshi.daikoku.ctrls.HomeController.sync() +#GET /api/users/:userId/session fr.maif.otoroshi.daikoku.ctrls.HomeController.session(userId) +#GET /api/cms fr.maif.otoroshi.daikoku.ctrls.HomeController.summary() +#DELETE /api/cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.deleteCmsPage(id) +#GET /api/cms/pages/:id fr.maif.otoroshi.daikoku.ctrls.HomeController.getCmsPage(id) + +POST /cms-api/sync fr.maif.otoroshi.daikoku.ctrls.CmsApiController.sync() +GET /cms-api/health fr.maif.otoroshi.daikoku.ctrls.CmsApiController.health() +GET /cms-api/version fr.maif.otoroshi.daikoku.ctrls.CmsApiController.version() +GET /cms-api/tenants/default fr.maif.otoroshi.daikoku.ctrls.CmsApiController.defaultTenant() + +GET /cms-api/cli/redirect fr.maif.otoroshi.daikoku.ctrls.CmsApiController.redirectToLoginPage() +GET /cms-api/cli/login fr.maif.otoroshi.daikoku.ctrls.CmsApiController.getLoginToken() +GET /cms-api/translations/_mail fr.maif.otoroshi.daikoku.ctrls.CmsApiController.getMailTranslations(domain: Option[String] ?= None) +GET /cms-api/apis fr.maif.otoroshi.daikoku.ctrls.CmsApiController.getAllApis() + +GET /cms-api/pages fr.maif.otoroshi.daikoku.ctrls.CmsApiController.findAll() +GET /cms-api/pages/:id fr.maif.otoroshi.daikoku.ctrls.CmsApiController.getCmsPage(id) +POST /cms-api/pages/sync fr.maif.otoroshi.daikoku.ctrls.CmsApiController.synchronizeWithLocalBundle() + +GET /cms-api/swagger.json fr.maif.otoroshi.daikoku.ctrls.CmsApiSwaggerController.swagger() +GET /cms-api/openapi.json fr.maif.otoroshi.daikoku.ctrls.CmsApiSwaggerController.swagger() GET / fr.maif.otoroshi.daikoku.ctrls.HomeController.index() GET /robots.txt fr.maif.otoroshi.daikoku.ctrls.HomeController.indexForRobots() @@ -43,7 +54,6 @@ POST /account/reset fr.maif.otoroshi.daikoku GET /account/reset fr.maif.otoroshi.daikoku.ctrls.LoginController.passwordResetValidation() GET /account/validate fr.maif.otoroshi.daikoku.ctrls.LoginController.createUserValidation() POST /account fr.maif.otoroshi.daikoku.ctrls.LoginController.createUser() -GET /cli/login fr.maif.otoroshi.daikoku.ctrls.LoginController.redirectToLoginPage() GET /api/auth/:provider/context fr.maif.otoroshi.daikoku.ctrls.LoginController.loginContext(provider) GET /auth/:provider/login fr.maif.otoroshi.daikoku.ctrls.LoginController.loginPage(provider) POST /auth/:provider/callback fr.maif.otoroshi.daikoku.ctrls.LoginController.login(provider) diff --git a/daikoku/javascript/src/apps/DaikokuApp.tsx b/daikoku/javascript/src/apps/DaikokuApp.tsx index b0b03652d..362a4afbb 100644 --- a/daikoku/javascript/src/apps/DaikokuApp.tsx +++ b/daikoku/javascript/src/apps/DaikokuApp.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect } from 'react'; import { Navigate } from 'react-router'; -import { BrowserRouter, Route, BrowserRouter as Router, Routes, createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { BrowserRouter, Route, BrowserRouter as Router, Routes, createBrowserRouter, RouterProvider, useSearchParams } from 'react-router-dom'; import { TeamBackOffice } from '../components/backoffice/TeamBackOffice'; import { Footer, LoginPage, SideBar, tenant } from '../components/utils'; @@ -130,7 +130,7 @@ export const DaikokuApp = () => { /> } + element={} /> @@ -469,6 +469,19 @@ export const DaikokuApp = () => { ); }; +const ToLogin = ({ tenant }) => { + + const [searchParams] = useSearchParams(); + + const redirect = searchParams.get('redirect') + const to = `/auth/${tenant.authProvider}/login` + + if (redirect) + return + else + return +} + const FrontOfficeRoute = (props: { title?: string, children: JSX.Element }) => { return ( diff --git a/daikoku/javascript/src/apps/DaikokuHomeApp.tsx b/daikoku/javascript/src/apps/DaikokuHomeApp.tsx index aaa89122c..b5654b782 100644 --- a/daikoku/javascript/src/apps/DaikokuHomeApp.tsx +++ b/daikoku/javascript/src/apps/DaikokuHomeApp.tsx @@ -315,7 +315,9 @@ export const TwoFactorAuthentication = () => { if (res.status >= 400) { setError(translate('2fa.wrong_code')); setCode(''); - } else if (res.redirected) window.location.href = res.url; + } else if (res.redirected) { + window.location.href = res.url; + } }); } } diff --git a/daikoku/javascript/src/components/adminbackoffice/cms/index.tsx b/daikoku/javascript/src/components/adminbackoffice/cms/index.tsx index 400a9b564..ccd949707 100644 --- a/daikoku/javascript/src/components/adminbackoffice/cms/index.tsx +++ b/daikoku/javascript/src/components/adminbackoffice/cms/index.tsx @@ -123,11 +123,11 @@ export const CMSOffice = () => { -
    - {/*
  • importRef.current?.click()}> - importRef.current = r} type="file" accept=".zip" className="form-control hide" onChange={loadFiles} /> - {translate('cms.import_all')} -
  • */} +
      */} +
    • importRef.current?.click()}> + importRef.current = r} type="file" accept=".zip" className="form-control hide" onChange={loadFiles} /> + {translate('cms.import_all')} +