facia/app/controllers/FaciaController.scala (499 lines of code) (raw):

package controllers import _root_.html.{BrazeEmailFormatter, HtmlTextExtractor} import agents.{DeeplyReadAgent, MostViewedAgent} import common._ import conf.Configuration import conf.switches.Switches.InlineEmailStyles import controllers.front._ import experiments.{ActiveExperiments, EuropeBetaFront, EuropeBetaFrontTest2} import http.HttpPreconnections import implicits.GUHeaders import layout.slices._ import layout._ import model.Cached.{CacheableResult, RevalidatableResult, WithoutRevalidationResult} import model._ import model.dotcomrendering.{DotcomFrontsRenderingDataModel, PageType} import model.facia.PressedCollection import model.pressed.CollectionConfig import net.logstash.logback.marker.Markers.append import pages.{FrontEmailHtmlPage, FrontHtmlPage} import play.api.libs.json._ import play.api.libs.ws.WSClient import play.api.mvc._ import play.twirl.api.Html import renderers.DotcomRenderingService import services.dotcomrendering.{FaciaPicker, RemoteRender} import services.fronts.{FrontJsonFapi, FrontJsonFapiLive} import services.{CollectionConfigWithId, ConfigAgent} import utils.TargetedCollections import views.html.fragments.containers.facia_cards.container import views.support.FaciaToMicroFormat2Helpers.getCollection import scala.concurrent.Future import scala.concurrent.Future.successful trait FaciaController extends BaseController with GuLogging with ImplicitControllerExecutionContext with implicits.Requests { val frontJsonFapi: FrontJsonFapi val ws: WSClient val mostViewedAgent: MostViewedAgent val deeplyReadAgent: DeeplyReadAgent val remoteRenderer: DotcomRenderingService = DotcomRenderingService() val assets: Assets implicit val context: ApplicationContext def applicationsRedirect(path: String)(implicit request: RequestHeader): Future[Result] = { val redirectPath = if (request.isJson) s"$path.json" else path successful( InternalRedirect.internalRedirect("applications", redirectPath, request.rawQueryStringOption.map("?" + _)), ) } def rssRedirect(path: String)(implicit request: RequestHeader): Future[Result] = { successful( InternalRedirect.internalRedirect( "rss_server", path, request.rawQueryStringOption.map("?" + _), ), ) } // ApplePay MerchantId def appleDeveloperMerchantId(): Action[AnyContent] = if (Configuration.environment.isProd) assets.at(path = "/public", file = "apple-developer-merchantid-domain-association-prod.txt") else assets.at(path = "/public", file = "apple-developer-merchantid-domain-association-code.txt") // Only used by dev-build for rending special urls such as lifeandstyle/home-and-garden def renderFrontPressSpecial(path: String): Action[AnyContent] = Action.async { implicit request => renderFrontPressResult(path) } // Needed as aliases for reverse routing def renderFrontJson(id: String): Action[AnyContent] = renderFront(id) def renderContainerJson(id: String): Action[AnyContent] = renderContainer(id, false) def renderContainerDataJson(id: String): Action[AnyContent] = Action.async { implicit request => getPressedCollection(id).map { case Some(collection) => val onwardItems = OnwardCollection.pressedCollectionToOnwardCollection(collection) Cached(CacheTime.Facia) { JsonComponent.fromWritable(onwardItems) } case None => Cached(CacheTime.NotFound)( WithoutRevalidationResult(NotFound(s"collection id $id does not exist")), ) } } def renderSomeFrontContainersMf2( count: Int, offset: Int, section: String = "", ): Action[AnyContent] = Action.async { implicit request => val e = Edition(request) val collectionsPath = if (section.isEmpty) e.id.toLowerCase else Editionalise(section, e) getSomeCollections(collectionsPath, count, offset, "none").map { collections => Cached(CacheTime.Facia) { JsonComponent( "items" -> JsArray(collections.map(getCollection)), ) } } } def renderContainerJsonWithFrontsLayout(id: String): Action[AnyContent] = renderContainer(id, true) // Needed as aliases for reverse routing def renderRootFrontRss(): Action[AnyContent] = renderFrontRss(path = "") def renderFrontRss(path: String): Action[AnyContent] = Action.async { implicit request => if (shouldEditionRedirect(path)) redirectTo(s"${Editionalise(path, Edition(request))}/rss") else if (!ConfigAgent.shouldServeFront(path)) rssRedirect(s"$path/rss") else renderFrontPressResult(path) } def rootEditionRedirect(): Action[AnyContent] = renderFront(path = "") def renderFrontHeadline(path: String): Action[AnyContent] = Action.async { implicit request => def notFound() = { FrontHeadline.headlineNotFound } if (!ConfigAgent.frontExistsInConfig(path)) { successful(Cached(CacheTime.Facia)(notFound())) } else { frontJsonFapi .get(path, liteRequestType) .map(_.fold[CacheableResult](notFound())(FrontHeadline.renderEmailHeadline)) .map(Cached(CacheTime.Facia)) } } def renderFront(path: String): Action[AnyContent] = Action.async { implicit request => if (shouldEditionRedirect(path)) redirectTo(Editionalise(path, Edition(request))) else if (!ConfigAgent.shouldServeFront(path) || request.getQueryString("page").isDefined) { applicationsRedirect(path) } else renderFrontPressResult(path) } private def shouldEditionRedirect(path: String)(implicit request: RequestHeader): Boolean = { val editionalisedPath = Editionalise(path, Edition(request)) (editionalisedPath != path) && request.getQueryString("page").isEmpty } def redirectTo(path: String)(implicit request: RequestHeader): Future[Result] = successful { val params = request.rawQueryStringOption.map(q => s"?$q").getOrElse("") Cached(CacheTime.Facia)(WithoutRevalidationResult(Found(LinkTo(s"/$path$params")))) } // Returns a stripped-down 'minimal' version of the 'lite' version of a PressedPage. // The minimal version of a Front contains only the `webTitle` and `collections` // from that Front. Some content items are filtered out (e.g. LinkSnaps) and some fields // are renamed. // It's used by a number of services, including the 'pressreader' edition feed, // see https://github.com/guardian/pressreader def renderFrontJsonMinimal(path: String): Action[AnyContent] = Action.async { implicit request => if (!ConfigAgent.frontExistsInConfig(path)) { successful( Cached(CacheTime.Facia)(JsonComponent.fromWritable(JsObject(Nil))), ) } else { frontJsonFapi.get(path, liteRequestType).map { resp => Cached(CacheTime.Facia)(JsonComponent.fromWritable(resp match { case Some(pressedPage) => FapiFrontJsonMinimal.get(pressedPage) case None => JsObject(Nil) })) } } } private def nonHtmlEmail(request: RequestHeader) = (request.isEmail && request.isHeadlineText) || request.isEmailJson || request.isEmailTxt // setting Vary header can be expensive (https://www.fastly.com/blog/best-practices-using-vary-header) // only set it for fronts with targeted collections private def withVaryHeader(result: Future[Result], targetedTerritories: Boolean) = if (targetedTerritories) { result.map(_.withHeaders(("Vary", GUHeaders.TERRITORY_HEADER))) } else result private def resultWithVaryHeader(result: CacheableResult, targetedTerritories: Boolean)(implicit request: RequestHeader, ) = withVaryHeader(successful(Cached(CacheTime.Facia)(result)), targetedTerritories) private def resultWithVaryAndPreloadHeader(result: CacheableResult, targetedTerritories: Boolean)(implicit request: RequestHeader, ) = withVaryHeader( successful( Cached(CacheTime.Facia)(result) .withPreload( Preload.config(request).getOrElse(context.applicationIdentity, Seq.empty), )(context, request) .withPreconnect(HttpPreconnections.defaultUrls), ), targetedTerritories, ) private[controllers] def renderFrontPressResult(path: String)(implicit request: RequestHeader): Future[Result] = { val futureFaciaPage = getFaciaPage(path) /** Europe Beta test: swaps the collections on the Europe network front with those on the hidden europe-beta front * for users participating in the test */ val futureFaciaPageWithEuropeBetaTest: Future[Option[(PressedPage, Boolean)]] = { if ( path == "europe" && (ActiveExperiments .isParticipating(EuropeBetaFront) || ActiveExperiments.isParticipating(EuropeBetaFrontTest2)) ) { val futureEuropeBetaPage = getFaciaPage("europe-beta") for { europePage <- futureFaciaPage europeBetaPage <- futureEuropeBetaPage } yield replaceFaciaPageCollections(europePage, europeBetaPage) } else { futureFaciaPage } } val customLogFieldMarker = append("requestId", request.headers.get("x-gu-xid").getOrElse("request-id-not-provided")) val networkFrontEdition = Edition.allEditions.find(_.networkFrontId == path) val deeplyRead = networkFrontEdition.map(deeplyReadAgent.getTrails) val futureResult = futureFaciaPageWithEuropeBetaTest.flatMap { case Some((faciaPage, _)) if nonHtmlEmail(request) => successful(Cached(CacheTime.RecentlyUpdated)(renderEmail(faciaPage))) case Some((faciaPage: PressedPage, targetedTerritories)) if FaciaPicker.getTier(faciaPage) == RemoteRender && !request.isJson => val pageType = PageType(faciaPage, request, context) logInfoWithRequestId( s"Front Geo Request (212): ${Edition(request).id} ${request.headers.toSimpleMap.getOrElse("X-GU-GeoLocation", "country:row")}", ) withVaryHeader( remoteRenderer.getFront( ws = ws, page = faciaPage, pageType = pageType, mostViewed = mostViewedAgent.mostViewed(Edition(request)), mostCommented = mostViewedAgent.mostCommented, mostShared = mostViewedAgent.mostShared, deeplyRead = deeplyRead, )(request), targetedTerritories, ) case Some((faciaPage: PressedPage, targetedTerritories)) if request.isRss => val body = TrailsToRss.fromPressedPage(faciaPage) withVaryHeader( successful(Cached(CacheTime.Facia)(RevalidatableResult(Ok(body).as("text/xml; charset=utf-8"), body))), targetedTerritories, ) case Some((faciaPage: PressedPage, targetedTerritories)) if request.isJson => val result = if (request.forceDCR) { logInfoWithRequestId( s"Front Geo Request (237): ${Edition(request).id} ${request.headers.toSimpleMap.getOrElse("X-GU-GeoLocation", "country:row")}", ) JsonComponent.fromWritable( DotcomFrontsRenderingDataModel( page = faciaPage, request = request, pageType = PageType(faciaPage, request, context), mostViewed = mostViewedAgent.mostViewed(Edition(request)), mostCommented = mostViewedAgent.mostCommented, mostShared = mostViewedAgent.mostShared, deeplyRead = deeplyRead, ), ) } else JsonFront(faciaPage) resultWithVaryHeader(result, targetedTerritories) case Some((faciaPage: PressedPage, targetedTerritories)) if request.isEmail || ConfigAgent.isEmailFront(path) => resultWithVaryHeader(renderEmail(faciaPage), targetedTerritories) case Some((faciaPage: PressedPage, targetedTerritories)) if TrailsToShowcase.isShowcaseFront(faciaPage) => resultWithVaryHeader(renderShowcaseFront(faciaPage), targetedTerritories) case Some((faciaPage: PressedPage, targetedTerritories)) => resultWithVaryAndPreloadHeader(RevalidatableResult.Ok(FrontHtmlPage.html(faciaPage)), targetedTerritories) case None => { successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) } } futureResult.failed.foreach { t: Throwable => logErrorWithRequestId(s"Failed rendering $path with $t", t) } futureResult } import PressedPage.pressedPageFormat /** Fetches facia page for path */ private[controllers] def getFaciaPage(path: String)(implicit request: RequestHeader, ): Future[Option[(PressedPage, Boolean)]] = frontJsonFapi.get(path, liteRequestType).flatMap { case Some(faciaPage: PressedPage) if faciaPage.collections.isEmpty && liteRequestType == LiteAdFreeType => frontJsonFapi.get(path, LiteType).map(_.map(f => (f, false))) case Some(faciaPage: PressedPage) => val pageContainsTargetedCollections = TargetedCollections.pageContainsTargetedCollections(faciaPage) val regionalFaciaPage = TargetedCollections.processTargetedCollections( faciaPage, request.territories, context.isPreview, pageContainsTargetedCollections, ) if (conf.Configuration.environment.stage == "CODE") { logInfoWithCustomFields( s"Rendering front $path, frontjson: ${Json.stringify(Json.toJson(faciaPage)(pressedPageFormat))}", List(), ) } Future.successful(Some(regionalFaciaPage, pageContainsTargetedCollections)) case None => Future.successful(None) } /** Swaps collections on a given facia page with those on another facia page. Set up for the Europe beta test where we * return europe-beta collections on the europe front if participating in the test */ private[controllers] def replaceFaciaPageCollections( baseFaciaPage: Option[(PressedPage, Boolean)], replacementFaciaPage: Option[(PressedPage, Boolean)], ): Option[(PressedPage, Boolean)] = { for { (basePage, _) <- baseFaciaPage (replacementPage, replacementTargetedTerritories) <- replacementFaciaPage } yield ( PressedPage( id = basePage.id, seoData = basePage.seoData, frontProperties = basePage.frontProperties, collections = replacementPage.collections, ), replacementTargetedTerritories, ) } private def renderEmail(faciaPage: PressedPage)(implicit request: RequestHeader) = { if (request.isHeadlineText) { FrontHeadline.renderEmailHeadline(faciaPage) } else { renderEmailFront(faciaPage) } } private def renderEmailFront(faciaPage: PressedPage)(implicit request: RequestHeader) = { val htmlResponse = FrontEmailHtmlPage.html(faciaPage) val htmResponseInlined = if (InlineEmailStyles.isSwitchedOn) InlineStyles(htmlResponse) else htmlResponse if (request.isEmailJson) { val htmlWithUtmLinks = BrazeEmailFormatter(htmResponseInlined) val emailJson = JsObject(Map("body" -> JsString(htmlWithUtmLinks.toString))) RevalidatableResult.Ok(emailJson) } else if (request.isEmailTxt) { val htmlWithUtmLinks = BrazeEmailFormatter(htmResponseInlined) val emailTxtJson = JsObject(Map("body" -> JsString(HtmlTextExtractor(htmlWithUtmLinks)))) RevalidatableResult.Ok(emailTxtJson) } else { RevalidatableResult.Ok(htmResponseInlined) } } protected def renderShowcaseFront(faciaPage: PressedPage)(implicit request: RequestHeader): RevalidatableResult = { val (rundownPanelOutcome, singleStoryPanelOutcomes, duplicateMap) = TrailsToShowcase.generatePanelsFrom(faciaPage) val showcase = TrailsToShowcase( feedTitle = faciaPage.metadata.title, url = Some(faciaPage.metadata.url), description = faciaPage.metadata.description, singleStoryPanels = singleStoryPanelOutcomes.flatMap(_.toOption), maybeRundownPanel = rundownPanelOutcome.toOption, ) // Google doesn't like <dc:date> elements in the showcase feed so we're going to remove them with a // tightly-focussed regex replacement. The <dc:date> values are added in the depths of the Rome // library which is not easy to intercept at that point. We can use this technique until we can figure // out a better way. In the meantime it'll stop the validator from complaining at us. val dcDateRegEx = """<dc:date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d+Z</dc:date>""".r val showcaseWithoutDcDates = dcDateRegEx.replaceAllIn(showcase, "") RevalidatableResult(Ok(showcaseWithoutDcDates).as("text/xml; charset=utf-8"), showcaseWithoutDcDates) } // Used by dev-build only def renderFrontPress(path: String): Action[AnyContent] = Action.async { implicit request => renderFrontPressResult(path) } def renderContainer(id: String, preserveLayout: Boolean = false): Action[AnyContent] = Action.async { implicit request => renderContainerView(id, preserveLayout) } private def renderContainerView(collectionId: String, preserveLayout: Boolean = false)(implicit request: RequestHeader, ): Future[Result] = { getPressedCollection(collectionId).map { collectionOption => collectionOption .map { collection => val config = ConfigAgent.getConfig(collectionId).getOrElse(CollectionConfig.empty) val containerLayout = { if (preserveLayout) Container.resolve(collection.collectionType) else Fixed(FixedContainers.fixedSmallSlowVI) } val containerDefinition = FaciaContainer.fromConfigWithId( 1, containerLayout, CollectionConfigWithId(collectionId, config), CollectionEssentials.fromPressedCollection(collection), hasMore = false, ) val html = container(containerDefinition, FrontProperties.empty) if (request.isJson) Cached(CacheTime.Facia) { JsonCollection(html) } else Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound("containers are only available as json"))) } .getOrElse( Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound(s"collection id $collectionId does not exist"))), ) } } def checkIfPaid(faciaCard: FaciaCard): Boolean = { faciaCard match { case c: ContentCard => c.properties.exists(_.isPaidFor) case _ => false } } def renderShowMore(path: String, collectionId: String): Action[AnyContent] = Action.async { implicit request => if (!ConfigAgent.frontExistsInConfig(path)) { successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) } else { frontJsonFapi.get(path, fullRequestType).flatMap { case Some(pressedPage) if request.forceDCR => val maybeResponse = for { collection <- pressedPage.collections.find(_.id == collectionId) } yield { successful(Cached(CacheTime.Facia) { val cards = collection.curated ++ collection.backfill val adFreeFilteredCards = cards.filter(c => !(c.properties.isPaidFor && request.isAdFree)) implicit val pressedContentFormat = PressedContentFormat.format JsonComponent.fromWritable(Json.toJson(adFreeFilteredCards)) }) } maybeResponse.getOrElse { successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) } case Some(pressedPage) => val containers = Front.fromPressedPage(pressedPage, Edition(request), adFree = request.isAdFree).containers val maybeResponse = for { (container, index) <- containers.zipWithIndex.find(_._1.dataId == collectionId) containerLayout <- container.containerLayout } yield { val remainingCards: Seq[FaciaCardAndIndex] = containerLayout.remainingCards.map(_.withFromShowMore) val adFreeFilteredCards: Seq[FaciaCardAndIndex] = if (request.isAdFree) { remainingCards.filter(c => !checkIfPaid(c.item)) } else { remainingCards } successful(Cached(CacheTime.Facia) { JsonComponent(views.html.fragments.containers.facia_cards.showMore(adFreeFilteredCards, index)) }) } maybeResponse getOrElse successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) case None => successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) } } } private object JsonCollection { def apply(html: Html)(implicit request: RequestHeader): RevalidatableResult = JsonComponent(html) } private object JsonFront { def apply(faciaPage: PressedPage)(implicit request: RequestHeader): RevalidatableResult = JsonComponent( "html" -> views.html.fragments.frontBody(faciaPage), "config" -> Json.parse(templates.js.javaScriptConfig(faciaPage).body), ) } /** Note, the way this method works is a bit circuitous. Firstly, it finds a front that contains the collection (via * the ConfigAgent, which is basically a cache of configuration for Guardian Fronts). It then looks up that front in * Frontend's S3 and extracts the full collection from it (with curated and backfill content etc.). It would be * easier if collections were stored somewhere independently of Fronts. */ private def getPressedCollection( collectionId: String, )(implicit request: RequestHeader): Future[Option[PressedCollection]] = ConfigAgent .getConfigsUsingCollectionId(collectionId) .headOption .map { path => frontJsonFapi .get(path, fullRequestType) .map(_.flatMap { faciaPage => faciaPage.collections.find { c => c.id == collectionId } }) } .getOrElse(successful(None)) private def getSomeCollections(path: String, num: Int, offset: Int = 0, containerNameToFilter: String)(implicit requestHeader: RequestHeader, ): Future[List[PressedCollection]] = frontJsonFapi.get(path, fullRequestType).map { maybePage => maybePage .map { faciaPage => // To-do: change the filter to only exclude thrashers and empty collections, not items such as the big picture faciaPage.collections .filterNot { collection => (collection.curated ++ collection.backfill).length < 2 || collection.displayName == "most popular" || collection.displayName.toLowerCase.contains(containerNameToFilter.toLowerCase) } .slice(offset, offset + num) } .getOrElse(Nil) } /* Google news hits this endpoint */ def renderCollectionRss(id: String): Action[AnyContent] = Action.async { implicit request => getPressedCollection(id).flatMap { case Some(collection) => successful { Cached(CacheTime.Facia) { val config: CollectionConfig = ConfigAgent.getConfig(id).getOrElse(CollectionConfig.empty) val webTitle = config.displayName.getOrElse("The Guardian") val body = TrailsToRss.fromFaciaContent( webTitle, collection.curatedPlusBackfillDeduplicated.flatMap(_.properties.maybeContent), "", None, ) RevalidatableResult(Ok(body).as("text/xml; charset=utf8"), body) } } case None => successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))) } } def renderAgentContents: Action[AnyContent] = Action { Ok(ConfigAgent.contentsAsJsonString) } def fullRequestType(implicit request: RequestHeader): PressedPageType = if (request.isAdFree) FullAdFreeType else FullType def liteRequestType(implicit request: RequestHeader): PressedPageType = if (request.isAdFree) LiteAdFreeType else LiteType def ampRsaPublicKey: Action[AnyContent] = { Action { // The private key is in the CAPI account, see the documentation at https://github.com/guardian/fastly-cache-purger Ok(Configuration.amp.flushPublicKey).as("text/plain") } } } class FaciaControllerImpl( val frontJsonFapi: FrontJsonFapiLive, val controllerComponents: ControllerComponents, val ws: WSClient, val mostViewedAgent: MostViewedAgent, val deeplyReadAgent: DeeplyReadAgent, val assets: Assets, )(implicit val context: ApplicationContext) extends FaciaController