diff --git a/auth/app/auth/AuthConfig.scala b/auth/app/auth/AuthConfig.scala index c16150cf8ad..be228e5e44e 100644 --- a/auth/app/auth/AuthConfig.scala +++ b/auth/app/auth/AuthConfig.scala @@ -1,8 +1,10 @@ package auth import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance class AuthConfig(resources: GridConfigResources) extends CommonConfig(resources) { - val rootUri: String = services.authBaseUri - val mediaApiUri: String = services.apiBaseUri + val rootUri: Instance => String = services.authBaseUri + val rootInstanceUri: Instance => String = services.authBaseInstanceUri + val mediaApiUri: Instance => String = services.apiBaseUri } diff --git a/auth/app/auth/AuthController.scala b/auth/app/auth/AuthController.scala index de4c8597b89..86bfd76f0e9 100644 --- a/auth/app/auth/AuthController.scala +++ b/auth/app/auth/AuthController.scala @@ -6,9 +6,11 @@ import com.gu.mediaservice.lib.auth.Authentication.{InnerServicePrincipal, Machi import com.gu.mediaservice.lib.auth.Permissions.{DeleteImage, ShowPaid, UploadImages} import com.gu.mediaservice.lib.auth.provider.AuthenticationProviders import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, Internal} +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.guardian.auth.PandaAuthenticationProvider +import com.gu.mediaservice.model.Instance import play.api.libs.json.Json -import play.api.mvc.{BaseController, ControllerComponents, Result} +import play.api.mvc.{AnyContent, BaseController, ControllerComponents, Request, Result} import java.net.URI import java.time.Instant @@ -19,15 +21,15 @@ class AuthController(auth: Authentication, providers: AuthenticationProviders, v override val controllerComponents: ControllerComponents, authorisation: Authorisation)(implicit ec: ExecutionContext) extends BaseController - with ArgoHelpers { + with ArgoHelpers with InstanceForRequest { - val indexResponse = { + def indexResponse()(implicit instance: Instance) = { val indexData = Map("description" -> "This is the Auth API") val indexLinks = List( - Link("root", config.mediaApiUri), - Link("login", config.services.loginUriTemplate), - Link("ui:logout", s"${config.rootUri}/logout"), - Link("session", s"${config.rootUri}/session") + Link("root", config.mediaApiUri(instance)), + Link("login", config.services.loginUriTemplate(instance)), + Link("ui:logout", s"${config.rootUri(instance)}/logout"), + Link("session", s"${config.rootInstanceUri(instance)}/session") ) respond(indexData, indexLinks) } @@ -45,7 +47,10 @@ class AuthController(auth: Authentication, providers: AuthenticationProviders, v } } - def index = auth { indexResponse } + def index = auth { request => + implicit val instance: Instance = instanceOf(request) + indexResponse() + } def session = auth { request => val showPaid = authorisation.hasPermissionTo(ShowPaid)(request.user) diff --git a/build.sbt b/build.sbt index 3169c231363..61e31d68506 100644 --- a/build.sbt +++ b/build.sbt @@ -182,7 +182,8 @@ lazy val thrall = playProject("thrall", 9002) "software.amazon.awssdk" % "dynamodb" % awsSdkV2Version, "com.gu" %% "kcl-pekko-stream" % "0.1.2", "org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test, - "com.google.protobuf" % "protobuf-java" % "3.19.6" + "com.google.protobuf" % "protobuf-java" % "3.19.6", + "software.amazon.awssdk" % "sqs" % awsSdkV2Version ), dependencyOverrides ++= Seq( "org.apache.pekko" %% "pekko-stream" % "1.0.3" diff --git a/collections/app/CollectionsComponents.scala b/collections/app/CollectionsComponents.scala index 116fc2e5c47..71415193088 100644 --- a/collections/app/CollectionsComponents.scala +++ b/collections/app/CollectionsComponents.scala @@ -15,7 +15,7 @@ class CollectionsComponents(context: Context) extends GridComponents(context, ne val notifications = new Notifications(config) val collections = new CollectionsController(auth, config, collectionsStore, controllerComponents) - val imageCollections = new ImageCollectionsController(auth, config, notifications, imageCollectionsStore, controllerComponents) + val imageCollections = new ImageCollectionsController(auth, notifications, imageCollectionsStore, controllerComponents) override val router = new Routes(httpErrorHandler, collections, imageCollections, management) diff --git a/collections/app/controllers/CollectionsController.scala b/collections/app/controllers/CollectionsController.scala index 1a3a4c8986e..92142ec9f07 100644 --- a/collections/app/controllers/CollectionsController.scala +++ b/collections/app/controllers/CollectionsController.scala @@ -6,16 +6,18 @@ import com.gu.mediaservice.lib.argo.model.{EmbeddedEntity, Link} import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.auth.Authentication.getIdentity import com.gu.mediaservice.lib.collections.CollectionsManager -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import lib.CollectionsConfig import model.Node import org.joda.time.DateTime import play.api.libs.functional.syntax._ import play.api.libs.json._ -import play.api.mvc.{BaseController, ControllerComponents} +import play.api.mvc.{BaseController, ControllerComponents, Request} import store.{CollectionsStore, CollectionsStoreError} import com.gu.mediaservice.lib.net.{URI => UriOps} import software.amazon.awssdk.services.dynamodb.model.AttributeValue +import com.gu.mediaservice.lib.net.{URI => UriOps} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future @@ -31,31 +33,31 @@ object AppIndex { } class CollectionsController(authenticated: Authentication, config: CollectionsConfig, store: CollectionsStore, - val controllerComponents: ControllerComponents) extends BaseController with ArgoHelpers { + val controllerComponents: ControllerComponents) extends BaseController with ArgoHelpers with InstanceForRequest { import CollectionsManager.{getCssColour, isValidPathBit, pathToUri, uriToPath} // Stupid name clash between Argo and Play import com.gu.mediaservice.lib.argo.model.{Action => ArgoAction} - def uri(u: String) = URI.create(u) - val collectionUri = uri(s"${config.rootUri}/collections") - def collectionUri(p: List[String] = Nil) = { + private def uri(u: String) = URI.create(u) + private def collectionUri()(implicit instance: Instance) = uri(s"${config.rootUri(instance)}/collections") + private def collectionUri(p: List[String] = Nil)(implicit instance: Instance) = { val path = if(p.nonEmpty) s"/${pathToUri(p)}" else "" - uri(s"${config.rootUri}/collections$path") + uri(s"${config.rootUri(instance)}/collections$path") } - val appIndex = AppIndex("media-collections", "The one stop shop for collections") - val indexLinks = List(Link("collections", collectionUri.toString)) + private val appIndex = AppIndex("media-collections", "The one stop shop for collections") + private def indexLinks()(implicit instance: Instance) = List(Link("collections", collectionUri().toString)) - def getNodeAction(n: Node[Collection]): Option[Link] = Some(Link("collection", collectionUri(n.fullPath).toString)) - def addChildAction(pathId: List[String] = Nil): Option[ArgoAction] = Some(ArgoAction("add-child", collectionUri(pathId), "POST")) - def addChildAction(n: Node[Collection]): Option[ArgoAction] = addChildAction(n.fullPath) - def removeNodeAction(n: Node[Collection]): Option[ArgoAction] = if (n.children.nonEmpty) None else Some( + private def getNodeAction(n: Node[Collection])(implicit instance: Instance): Option[Link] = Some(Link("collection", collectionUri(n.fullPath).toString)) + private def addChildAction(pathId: List[String] = Nil)(implicit instance: Instance): Option[ArgoAction] = Some(ArgoAction("add-child", collectionUri(pathId), "POST")) + private def addChildAction(n: Node[Collection])(implicit instance: Instance): Option[ArgoAction] = addChildAction(n.fullPath) + private def removeNodeAction(n: Node[Collection])(implicit instance: Instance): Option[ArgoAction] = if (n.children.nonEmpty) None else Some( ArgoAction("remove", collectionUri(n.fullPath), "DELETE") ) def index = authenticated { req => - respond(appIndex, links = indexLinks) + respond(appIndex, links = indexLinks()(instanceOf(req))) } def collectionNotFound(path: String) = @@ -70,15 +72,16 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo def storeError(message: String) = respondError(InternalServerError, "collection-store-error", message) - def getActions(n: Node[Collection]): List[ArgoAction] = { + def getActions(n: Node[Collection])(implicit instance: Instance): List[ArgoAction] = { List(addChildAction(n), removeNodeAction(n)).flatten } - def getLinks(n: Node[Collection]): List[Link] = { + private def getLinks(n: Node[Collection])(implicit instance: Instance): List[Link] = { List(getNodeAction(n)).flatten } def correctedCollections = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) store.getAll flatMap { collections => val tree = Node.fromList[Collection]( collections, @@ -100,14 +103,15 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } } - def allCollections = store.getAll.map { collections => + def allCollections()(implicit instance: Instance)= store.getAll.map { collections => Node.fromList[Collection]( collections, (collection) => collection.path, (collection) => collection.description) } - def getCollection(collectionPathId: String) = authenticated.async { + def getCollection(collectionPathId: String) = authenticated.async { request => + implicit val instance: Instance = instanceOf(request) store.get(uriToPath(collectionPathId)).map { case Some(collection) => val node = Node(collection.path.last, Nil, collection.path, collection.path, Some(collection)) @@ -120,7 +124,18 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } def getCollections = authenticated.async { req => - allCollections.map { tree => + implicit val instance: Instance = instanceOf(req) + implicit def asArgo: Writes[Node[Collection]] = ( + (__ \ "basename").write[String] ~ + (__ \ "children").lazyWrite[CollectionsEntity](Writes[CollectionsEntity] + // This is so we don't have to rewrite the Write[Seq[T]] + (seq => Json.toJson(seq))).contramap(collectionsEntity(_: List[Node[Collection]])) ~ + (__ \ "fullPath").write[List[String]] ~ + (__ \ "data").writeNullable[Collection] ~ + (__ \ "cssColour").writeNullable[String] + )(node => (node.basename, node.children, node.fullPath, node.data, getCssColour(node.fullPath))) + + allCollections().map { tree => respond( Json.toJson(tree)(asArgo), actions = List(addChildAction()).flatten @@ -134,6 +149,7 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo def addChildToRoot = addChildTo(None) def addChildToCollection(collectionPathId: String) = addChildTo(Some(collectionPathId)) def addChildTo(collectionPathId: Option[String]) = authenticated.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[String] map { child => if (isValidPathBit(child)) { val path = collectionPathId.map(uriToPath).getOrElse(Nil) :+ child @@ -153,8 +169,8 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } type MaybeTree = Option[Node[Collection]] - def hasChildren(path: List[String]): Future[Boolean] = - allCollections.map { tree => + private def hasChildren(path: List[String])(implicit instance: Instance) = + allCollections().map { tree => // Traverse the tree using the path val maybeTree = path @@ -168,6 +184,7 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } def removeCollection(collectionPath: String) = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) val path = CollectionsManager.uriToPath(UriOps.encodePlus(collectionPath)) hasChildren(path).flatMap { noRemove => @@ -195,18 +212,8 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo )(node => (node.basename, node.children, node.fullPath, node.data)) type CollectionsEntity = Seq[EmbeddedEntity[Node[Collection]]] - implicit def asArgo: Writes[Node[Collection]] = ( - (__ \ "basename").write[String] ~ - (__ \ "children").lazyWrite[CollectionsEntity](Writes[CollectionsEntity] - // This is so we don't have to rewrite the Write[Seq[T]] - (seq => Json.toJson(seq))).contramap(collectionsEntity) ~ - (__ \ "fullPath").write[List[String]] ~ - (__ \ "data").writeNullable[Collection] ~ - (__ \ "cssColour").writeNullable[String] - )(node => (node.basename, node.children, node.fullPath, node.data, getCssColour(node.fullPath))) - - def collectionsEntity(nodes: List[Node[Collection]]): CollectionsEntity = { + private def collectionsEntity(nodes: List[Node[Collection]])(implicit instance: Instance): CollectionsEntity = { nodes.map(n => EmbeddedEntity(collectionUri(n.fullPath), Some(n), links = getLinks(n), actions = getActions(n))) } diff --git a/collections/app/controllers/ImageCollectionsController.scala b/collections/app/controllers/ImageCollectionsController.scala index d1e65c464fe..ae26f7746bf 100644 --- a/collections/app/controllers/ImageCollectionsController.scala +++ b/collections/app/controllers/ImageCollectionsController.scala @@ -5,12 +5,12 @@ import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.auth.Authentication.getIdentity import com.gu.mediaservice.lib.aws.{NoItemFound, UpdateMessage} import com.gu.mediaservice.lib.collections.CollectionsManager +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.net.{URI => UriOps} -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import com.gu.mediaservice.syntax.MessageSubjects -import lib.{CollectionsConfig, Notifications} +import lib.Notifications import org.joda.time.DateTime -import play.api.libs.json.Json import play.api.mvc.{BaseController, ControllerComponents} import store.ImageCollectionsStore @@ -18,14 +18,15 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -class ImageCollectionsController(authenticated: Authentication, config: CollectionsConfig, notifications: Notifications, +class ImageCollectionsController(authenticated: Authentication, notifications: Notifications, imageCollectionsStore: ImageCollectionsStore, override val controllerComponents: ControllerComponents) - extends BaseController with MessageSubjects with ArgoHelpers { + extends BaseController with MessageSubjects with ArgoHelpers with InstanceForRequest { import CollectionsManager.onlyLatest def getCollections(id: String) = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) imageCollectionsStore.get(id).map { collections => respond(onlyLatest(collections)) } recover { @@ -34,6 +35,7 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti } def addCollection(id: String) = authenticated.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[List[String]].map { path => val collection = Collection.build(path, ActionData(getIdentity(req.user), DateTime.now())) imageCollectionsStore.add(id, collection) @@ -44,6 +46,7 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti def removeCollection(id: String, collectionString: String) = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) val path = CollectionsManager.uriToPath(UriOps.encodePlus(collectionString)) // We do a get to be able to find the index of the current collection, then remove it. // Given that we're using Dynamo Lists this seemed like a decent way to do it. @@ -63,9 +66,9 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti } } - def publish(id: String)(collections: List[Collection]): List[Collection] = { + def publish(id: String)(collections: List[Collection])(implicit instance: Instance): List[Collection] = { val onlyLatestCollections = onlyLatest(collections) - val updateMessage = UpdateMessage(subject = SetImageCollections, id = Some(id), collections = Some(onlyLatestCollections)) + val updateMessage = UpdateMessage(subject = SetImageCollections, id = Some(id), collections = Some(onlyLatestCollections), instance = instance) notifications.publish(updateMessage) onlyLatestCollections } diff --git a/collections/app/lib/CollectionsConfig.scala b/collections/app/lib/CollectionsConfig.scala index 9570409bc2d..20dcb8ff52e 100644 --- a/collections/app/lib/CollectionsConfig.scala +++ b/collections/app/lib/CollectionsConfig.scala @@ -1,11 +1,12 @@ package lib import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance class CollectionsConfig(resources: GridConfigResources) extends CommonConfig(resources) { val collectionsTable = string("dynamo.table.collections") val imageCollectionsTable = string("dynamo.table.imageCollections") - val rootUri = services.collectionsBaseUri + val rootUri: Instance => String = services.collectionsBaseUri } diff --git a/collections/app/store/CollectionsStore.scala b/collections/app/store/CollectionsStore.scala index 1172dcda7da..52ef88ed4d7 100644 --- a/collections/app/store/CollectionsStore.scala +++ b/collections/app/store/CollectionsStore.scala @@ -1,19 +1,18 @@ package store +import cats.implicits._ import com.gu.mediaservice.lib.collections.CollectionsManager -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import org.joda.time.DateTime import org.scanamo.generic.auto.genericDerivedFormat -import org.scanamo.{DynamoFormat, ScanamoAsync, Table} import org.scanamo.syntax._ +import org.scanamo.{DynamoFormat, ScanamoAsync, Table} import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -import cats.implicits._ -import org.scanamo.generic.semiauto.FieldName -case class Record(id: String, collection: Collection) +case class Record(id: String, collection: Collection, instance: String) class CollectionsStore(val tableName: String, client: DynamoDbAsyncClient) extends DynamoHelpers { import org.scanamo.generic.semiauto._ @@ -25,24 +24,24 @@ class CollectionsStore(val tableName: String, client: DynamoDbAsyncClient) exten private lazy val collectionsTable = Table[Record](tableName) - def getAll: Future[List[Collection]] = { - ScanamoAsync(client).exec(collectionsTable.scan()).map(_.sequence).flatMap(res => + def getAll(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(collectionsTable.query("instance" === instance.id)).map(_.sequence).flatMap(res => handleResponse(res)(records => records.map(_.collection)) ) } - def add(collection: Collection): Future[Collection] = { + def add(collection: Collection)(implicit instance: Instance): Future[Collection] = { ScanamoAsync(client).exec( collectionsTable.update( - "id" === collection.pathId, + "id" === collection.pathId and "instance" === instance.id, set("collection", collection) ) ).flatMap(res => handleResponse(res)(record => record.collection)) } - def get(collectionPath: List[String]): Future[Option[Collection]] = { + def get(collectionPath: List[String])(implicit instance: Instance): Future[Option[Collection]] = { val path = CollectionsManager.pathToPathId(collectionPath) - ScanamoAsync(client).exec(collectionsTable.get("id" === path)).flatMap(maybeEither => + ScanamoAsync(client).exec(collectionsTable.get("id" === path and "instance" === instance.id)).flatMap(maybeEither => maybeEither.fold[Future[Option[Collection]]]( Future.successful(None) )(res => @@ -51,9 +50,9 @@ class CollectionsStore(val tableName: String, client: DynamoDbAsyncClient) exten ) } - def remove(collectionPath: List[String]): Future[Unit] = { + def remove(collectionPath: List[String])(implicit instance: Instance): Future[Unit] = { val path = CollectionsManager.pathToPathId(collectionPath) - ScanamoAsync(client).exec(collectionsTable.delete("id" === path)) + ScanamoAsync(client).exec(collectionsTable.delete("id" === path and "instance" === instance.id)) } } diff --git a/collections/app/store/ImageCollectionsStore.scala b/collections/app/store/ImageCollectionsStore.scala index b73c9ba5b95..a9970d03a9f 100644 --- a/collections/app/store/ImageCollectionsStore.scala +++ b/collections/app/store/ImageCollectionsStore.scala @@ -1,6 +1,6 @@ package store -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import lib.CollectionsConfig import org.joda.time.DateTime import org.scanamo.generic.auto.genericDerivedFormat @@ -14,7 +14,7 @@ import org.scanamo.syntax._ import scala.concurrent.ExecutionContext.Implicits.global -case class ImageRecord(id: String, collections: List[Collection]) +case class ImageRecord(id: String, instance: String, collections: List[Collection]) class ImageCollectionsStore(val tableName: String, val client: DynamoDbAsyncClient) extends DynamoHelpers { @@ -27,8 +27,8 @@ class ImageCollectionsStore(val tableName: String, val client: DynamoDbAsyncClie private lazy val imageCollectionsTable = Table[ImageRecord](tableName) - def get(id: String): Future[List[Collection]] = { - ScanamoAsync(client).exec(imageCollectionsTable.get("id" === id)).flatMap(maybeEither => + def get(id: String)(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.get("id" === id and "instance" === instance.id)).flatMap(maybeEither => maybeEither.fold[Future[List[Collection]]]( Future.failed(NoItemFound) )(res => @@ -37,14 +37,14 @@ class ImageCollectionsStore(val tableName: String, val client: DynamoDbAsyncClie ) } - def add(id: String, collection: Collection): Future[List[Collection]] = { - ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id, append("collections", collection))).flatMap(res => { + def add(id: String, collection: Collection)(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id and "instance" === instance.id, append("collections", collection))).flatMap(res => { handleResponse(res)(res => res.collections) }) } - def update(id: String, collections: List[Collection]): Future[List[Collection]] = { - ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id, set("collections", collections))).flatMap(res => { + def update(id: String, collections: List[Collection])(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id and "instance" === instance.id, set("collections", collections))).flatMap(res => { handleResponse(res)(res => res.collections) }) } diff --git a/collections/test/store/CollectionsStoreTest.scala b/collections/test/store/CollectionsStoreTest.scala index f4a706456a6..78c2f5a9e71 100644 --- a/collections/test/store/CollectionsStoreTest.scala +++ b/collections/test/store/CollectionsStoreTest.scala @@ -1,6 +1,6 @@ package store -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import org.joda.time.DateTime import org.scalatest.BeforeAndAfterAll import org.scalatest.concurrent.ScalaFutures @@ -33,6 +33,8 @@ class CollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures wi region(Region.of(dynamoContainer.getRegion)). credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + private implicit val instance: Instance = Instance("an-instance") + private val collectionsTable = "test-collections-table-" + UUID.randomUUID().toString private val collectionsTableForAllTest = "test-collections-table-" + UUID.randomUUID().toString private val store = new CollectionsStore(collectionsTable, dynamoClient) @@ -41,10 +43,12 @@ class CollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures wi override def beforeAll(): Unit = { def createTableRequestFor(tableName: String): CreateTableRequest = { val attributeDefinitions = List( - AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build() + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build() ) val keySchema = List( - KeySchemaElement.builder.attributeName("id").keyType(KeyType.HASH).build() + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() ) val provisionedThroughput = ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build() val request = CreateTableRequest.builder diff --git a/collections/test/store/ImageCollectionsStoreTest.scala b/collections/test/store/ImageCollectionsStoreTest.scala index 994131aaf01..77e366f42ba 100644 --- a/collections/test/store/ImageCollectionsStoreTest.scala +++ b/collections/test/store/ImageCollectionsStoreTest.scala @@ -1,6 +1,6 @@ package store -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import org.joda.time.DateTime import org.scalatest.BeforeAndAfterAll import org.scalatest.concurrent.ScalaFutures @@ -30,15 +30,19 @@ class ImageCollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutur region(Region.of(dynamoContainer.getRegion)). credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + private implicit val instance: Instance = Instance("an-instance") + private val imageCollectionsTable = "test-image-collections-table-" + UUID.randomUUID().toString private val store = new ImageCollectionsStore(imageCollectionsTable, dynamoClient) override def beforeAll(): Unit = { val attributeDefinitions = List( - AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build() + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build() ) val keySchema = List( - KeySchemaElement.builder.attributeName("id").keyType(KeyType.HASH).build() + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() ) val provisionedThroughput = ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build() val request = CreateTableRequest.builder diff --git a/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala index c3207891d53..f3df2707dda 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala @@ -1,19 +1,19 @@ package com.gu.mediaservice -import java.net.URL import com.gu.mediaservice.GridClient.{Error, Found, NotFound, Response} import com.gu.mediaservice.lib.config.Services -import com.gu.mediaservice.model.{Collection, Crop, Edits, Image, ImageMetadata, ImageStatusRecord, SourceImage, SyndicationRights} import com.gu.mediaservice.model.leases.LeasesByMedia import com.gu.mediaservice.model.usage.Usage +import com.gu.mediaservice.model._ import com.typesafe.scalalogging.LazyLogging import play.api.http.HeaderNames -import play.api.libs.json.{JsArray, JsObject, JsTrue, JsValue, Json, Reads} +import play.api.libs.json._ +import play.api.libs.ws.{WSClient, WSRequest, WSResponse} -import scala.concurrent.duration.{Duration, DurationInt} +import java.net.URL +import scala.concurrent.duration.{Duration, DurationInt, SECONDS} import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} -import play.api.libs.ws.{WSClient, WSRequest, WSResponse} object ClientResponse { case class Message(errorMessage: String, downstreamErrorMessage: String) @@ -38,7 +38,7 @@ object ClientResponse { case class ClientErrorMessages(errorMessage: String, downstreamErrorMessage: String) object GridClient extends LazyLogging { - def apply(services: Services, originUri: String)(implicit wsClient: WSClient): GridClient = + def apply(services: Services, originUri: Instance => String)(implicit wsClient: WSClient): GridClient = new GridClient(services, originUri) sealed trait Response { @@ -96,7 +96,7 @@ object GridClient extends LazyLogging { } -class GridClient(services: Services, originDomain: String)(implicit wsClient: WSClient) extends LazyLogging { +class GridClient(services: Services, originDomain: Instance => String)(implicit wsClient: WSClient) extends LazyLogging { /* * `requestTimeout` will set the max duration of the request before timing out. You may also want to increase the @@ -134,8 +134,8 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getProjectionDiff(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[JsValue]] = { - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/projection/diff") + def getProjectionDiff(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[JsValue]] = { + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/projection/diff") makeGetRequestAsync(url, authFn, requestTimeout = Some(120.seconds)).map { case Found(json, _) => Some(json) case NotFound(_, _) => None @@ -144,8 +144,8 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } def getImageLoaderProjection(mediaId: String, authFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext): Future[Option[Image]] = { - getImageLoaderProjection(mediaId, services.projectionBaseUri, authFn) + (implicit ec: ExecutionContext, instance: Instance): Future[Option[Image]] = { + getImageLoaderProjection(mediaId, services.projectionBaseUri(instance), authFn) } def getImageLoaderProjection(mediaId: String, imageLoaderEndpoint: String, authFn: WSRequest => WSRequest) @@ -159,9 +159,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getLeases(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[LeasesByMedia] = { + def getLeases(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[LeasesByMedia] = { logger.info("attempt to get leases") - val url = new URL(s"${services.leasesBaseUri}/leases/media/$mediaId") + val url = new URL(s"${services.leasesBaseUri(instance)}/leases/media/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[LeasesByMedia] case NotFound(_, _) => LeasesByMedia.empty @@ -169,9 +169,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getCollections(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Collection]] = { + def getCollections(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Collection]] = { logger.info("attempt to get collections") - val url = new URL(s"${services.collectionsBaseUri}/images/$mediaId") + val url = new URL(s"${services.collectionsBaseUri(instance)}/images/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[List[Collection]] case NotFound(_, _) => Nil @@ -179,9 +179,24 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getEdits(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[Edits]] = { + def createCollection(name: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[Collection]] = { + val url = new URL(s"${services.collectionsBaseUri(instance)}/collections") + val request = wsClient.url(url.toString).withRequestTimeout(Duration(10, SECONDS)) + val authorisedRequest = authFn(request) + val data = Json.obj("data" -> JsString(name)) + authorisedRequest.post(data).map { response => + logger.info("Got new collection response: " + response.body) + validateResponse(response, url) match { + case Found(json, _) => (json \ "data" \ "data").toOption.map(_.as[Collection]) + case NotFound(_, _) => None + case e@Error(_, _, _) => e.logErrorAndThrowException() + } + } + } + + def getEdits(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[Edits]] = { logger.info("attempt to get edits") - val url = new URL(s"${services.metadataBaseUri}/edits/$mediaId") + val url = new URL(s"${services.metadataBaseUri(instance)}/edits/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[Edits]) case NotFound(_, _) => None @@ -189,9 +204,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getSoftDeletedMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[ImageStatusRecord]] = { + def getSoftDeletedMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[ImageStatusRecord]] = { logger.info("attempt to get soft deleted metadata") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/softDeletedMetadata") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/softDeletedMetadata") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[ImageStatusRecord]) case NotFound(_, _) => None @@ -199,9 +214,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getUploadedBy(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[String]] = { + def getUploadedBy(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[String]] = { logger.info("attempt to get uploadedBy") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/uploadedBy") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/uploadedBy") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[String]) case NotFound(_, _) => None @@ -209,9 +224,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getCrops(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Crop]] = { + def getCrops(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Crop]] = { logger.info("attempt to get crops") - val url = new URL(s"${services.cropperBaseUri}/crops/$mediaId") + val url = new URL(s"${services.cropperBaseUri(instance)}/crops/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[List[Crop]] case NotFound(_, _) => Nil @@ -219,7 +234,7 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getUsages(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Usage]] = { + def getUsages(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Usage]] = { logger.info("attempt to get usages") def unpackUsagesFromEntityResponse(resBody: JsValue): List[JsValue] = { @@ -227,7 +242,7 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS .map(entity => (entity.as[JsObject] \ "data").as[JsValue]).toList } - val url = new URL(s"${services.usageBaseUri}/usages/media/$mediaId") + val url = new URL(s"${services.usageBaseUri(instance)}/usages/media/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => unpackUsagesFromEntityResponse(json).map(_.as[Usage]) case NotFound(_, _) => Nil @@ -235,9 +250,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getSourceImage(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[SourceImage] = { + def getSourceImage(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[SourceImage] = { logger.info("attempt to get image") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId") makeGetRequestAsync(url, authFn, queryStringParameters = Some(Seq("include" -> "fileMetadata"))) map { case Found(json, _) => json.as[SourceImage] case nf@NotFound(_, _) => Error(nf.status, url, nf.underlying).logErrorAndThrowException() @@ -245,9 +260,9 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[ImageMetadata] = { + def getMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[ImageMetadata] = { logger.info("attempt to get metadata") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data" \ "metadata").as[ImageMetadata] case nf@NotFound(_, _) => Error(nf.status, url, nf.underlying).logErrorAndThrowException() @@ -255,8 +270,8 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def getSyndicationRights(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = { - val url = new URL(s"${services.metadataBaseUri}/metadata/$mediaId/syndication") + def getSyndicationRights(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance) = { + val url = new URL(s"${services.metadataBaseUri(instance)}/metadata/$mediaId/syndication") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[SyndicationRights]) case _: NotFound => None @@ -264,15 +279,15 @@ class GridClient(services: Services, originDomain: String)(implicit wsClient: WS } } - def postUsage(usageType: String, data: JsObject, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = { - val url = new URL(s"${services.usageBaseUri}/usages/$usageType") + def postUsage(usageType: String, data: JsObject, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance) = { + val url = new URL(s"${services.usageBaseUri(instance)}/usages/$usageType") val request: WSRequest = wsClient.url(url.toString) val authorisedRequest = authFn(request) authorisedRequest.post(Json.obj("data" -> data)).map { response => validateResponse(response, url)} } - def putArchived(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = { - val url = new URL(s"${services.metadataBaseUri}/metadata/$mediaId/archived") + def putArchived(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance) = { + val url = new URL(s"${services.metadataBaseUri(instance)}/metadata/$mediaId/archived") val request = authFn(wsClient.url(url.toString)) request.put(Json.obj("data" -> JsTrue)).map { response => validateResponse(response, url)} } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala b/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala index 463c94500ea..ca9f75fd8ca 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala @@ -4,11 +4,12 @@ import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} import com.gu.mediaservice.model._ import org.joda.time.DateTime import play.api.libs.ws.WSRequest +import play.api.mvc.RequestHeader import scala.concurrent.{ExecutionContext, Future} object ImageDataMerger extends GridLogging { - def aggregate(image: Image, gridClient: GridClient, authFunction: WSRequest => WSRequest)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { + def aggregate(image: Image, gridClient: GridClient, authFunction: WSRequest => WSRequest)(implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Image] = { logger.info(logMarker, s"starting to aggregate image") val mediaId = image.id // NB original metadata should already be added, cleaned, and copied to metadata. diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala index 9ec8633e275..022eacf212f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala @@ -1,12 +1,19 @@ package com.gu.mediaservice.lib import _root_.play.api.libs.json._ +import com.gu.mediaservice.lib.logging.GridLogging -trait ImageId { +trait ImageId extends GridLogging { - def withImageId[A](image: JsValue)(f: String => A): A = { - (image \ "id").validate[String].asOpt.map(f).getOrElse { - sys.error(s"No id field present in message body: $image") + def withImageIdAndInstance[A](image: JsValue)(f: (String, String) => A): A = { + (for { + id <- (image \ "id").validate[String].asOpt + instance <- (image \ "instance").validate[String].asOpt + } yield { + (id, instance) + }).map((a: (String, String)) => f(a._1, a._2)) + .getOrElse { + sys.error(s"No id and/or instance field present in message body: $image") } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala index 9483290c4fe..ef96e09cf12 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala @@ -1,26 +1,28 @@ package com.gu.mediaservice.lib -import java.io.File -import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.{MimeType, Png} +import com.gu.mediaservice.model.{Instance, MimeType} +import com.typesafe.scalalogging.StrictLogging import org.joda.time.DateTime -import software.amazon.awssdk.core.exception.SdkClientException -import software.amazon.awssdk.services.s3.model.{Delete, DeleteObjectsRequest, HeadObjectRequest, NoSuchKeyException, ObjectIdentifier} +import software.amazon.awssdk.services.s3.model.{Delete, DeleteObjectsRequest, ObjectIdentifier} +import java.io.File import scala.concurrent.Future import scala.jdk.CollectionConverters._ object ImageIngestOperations { - def fileKeyFromId(id: String): String = id.take(6).mkString("/") + "/" + id + def fileKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + snippetForId(id) + + def optimisedPngKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + "optimised/" + snippetForId(id: String) - def optimisedPngKeyFromId(id: String): String = "optimised/" + fileKeyFromId(id: String) + private def snippetForId(id: String) = id.take(6).mkString("/") + "/" + id } class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config: CommonConfig, isVersionedS3: Boolean = false) - extends S3ImageStorage(config) { + extends S3ImageStorage(config) with StrictLogging { import ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId} @@ -32,19 +34,28 @@ class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config } private def storeOriginalImage(storableImage: StorableOriginalImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(imageBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = instanceAwareOriginalImageKey(storableImage) + logger.info(s"Storing original image to instance specific key:$imageBucket / $instanceSpecificKey") + storeImage(imageBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), storableImage.meta, overwrite = false) + } private def storeThumbnailImage(storableImage: StorableThumbImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(thumbnailBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = instanceAwareThumbnailImageKey(storableImage) + logger.info(s"Storing thumbnail to instance specific key: $thumbnailBucket / $instanceSpecificKey") + storeImage(thumbnailBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), overwrite = true) + } private def storeOptimisedImage(storableImage: StorableOptimisedImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(imageBucket, optimisedPngKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = optimisedPngKeyFromId(storableImage.id)(storableImage.instance) + logger.info(s"Storing optimised image to instance specific key: $thumbnailBucket / $instanceSpecificKey") + storeImage(imageBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), overwrite = true) + } private def bulkDelete(bucket: String, keys: List[String]): Future[Map[String, Boolean]] = keys match { @@ -67,15 +78,22 @@ class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config } } - def deleteOriginal(id: String)(implicit logMarker: LogMarker): Future[Unit] = if(isVersionedS3) deleteVersionedImage(imageBucket, fileKeyFromId(id)) else deleteImage(imageBucket, fileKeyFromId(id)) - def deleteOriginals(ids: Set[String]) = bulkDelete(imageBucket, ids.map(fileKeyFromId).toList) - def deleteThumbnail(id: String)(implicit logMarker: LogMarker): Future[Unit] = deleteImage(thumbnailBucket, fileKeyFromId(id)) - def deleteThumbnails(ids: Set[String]) = bulkDelete(thumbnailBucket, ids.map(fileKeyFromId).toList) - def deletePNG(id: String)(implicit logMarker: LogMarker): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id)) - def deletePNGs(ids: Set[String]) = bulkDelete(imageBucket, ids.map(optimisedPngKeyFromId).toList) + def deleteOriginal(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = if(isVersionedS3) deleteVersionedImage(imageBucket, fileKeyFromId(id)) else deleteImage(imageBucket, fileKeyFromId(id)) + def deleteOriginals(ids: Set[String])(implicit instance: Instance) = bulkDelete(imageBucket, ids.map(id => fileKeyFromId(id)).toList) + def deleteThumbnail(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(thumbnailBucket, fileKeyFromId(id)) + def deleteThumbnails(ids: Set[String])(implicit instance: Instance) = bulkDelete(thumbnailBucket, ids.map(id => fileKeyFromId(id)).toList) + def deletePNG(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id)) + def deletePNGs(ids: Set[String])(implicit instance: Instance) = bulkDelete(imageBucket, ids.map(id => optimisedPngKeyFromId(id)).toList) - def doesOriginalExist(id: String): Boolean = { + def doesOriginalExist(id: String)(implicit instance: Instance): Boolean = this.doesObjectExist(imageBucket, fileKeyFromId(id)) + + private def instanceAwareOriginalImageKey(storableImage: StorableOriginalImage) = { + fileKeyFromId(storableImage.id)(storableImage.instance) + } + + private def instanceAwareThumbnailImageKey(storableImage: StorableThumbImage) = { + fileKeyFromId(storableImage.id)(storableImage.instance) } } @@ -85,11 +103,12 @@ sealed trait ImageWrapper { val file: File val mimeType: MimeType val meta: Map[String, String] + val instance: Instance } sealed trait StorableImage extends ImageWrapper { def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( thumbBucket, - ImageIngestOperations.fileKeyFromId(id), + ImageIngestOperations.fileKeyFromId(id)(instance), file, Some(mimeType), lastModified = None, @@ -97,21 +116,21 @@ sealed trait StorableImage extends ImageWrapper { ) } -case class StorableThumbImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage -case class StorableOriginalImage(id: String, file: File, mimeType: MimeType, lastModified: DateTime, meta: Map[String, String] = Map.empty) extends StorableImage { +case class StorableThumbImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage +case class StorableOriginalImage(id: String, file: File, mimeType: MimeType, lastModified: DateTime, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage { override def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( thumbBucket, - ImageIngestOperations.fileKeyFromId(id), + ImageIngestOperations.fileKeyFromId(id)(instance), file, Some(mimeType), lastModified = Some(lastModified), meta ) } -case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage { +case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage { override def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( thumbBucket, - ImageIngestOperations.optimisedPngKeyFromId(id), + ImageIngestOperations.optimisedPngKeyFromId(id)(instance), file, Some(mimeType), lastModified = None, @@ -129,8 +148,8 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me * Can be used in order to skip e.g. the stripping of incorrect colour profiles, * as in this case we have already inferred the profile upstream. */ -case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false) extends ImageWrapper { - def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta) - def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta) +case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false, instance: Instance) extends ImageWrapper { + def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta, instance) + def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta, instance) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala index 9de53d94905..0cc3a146a0e 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala @@ -1,11 +1,10 @@ package com.gu.mediaservice.lib import java.io.File - import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.aws.S3Object import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.MimeType +import com.gu.mediaservice.model.{Instance, MimeType} import scala.concurrent.Future @@ -13,7 +12,7 @@ class ImageQuarantineOperations(quarantineBucket: String, config: CommonConfig, extends S3ImageStorage(config) { def storeQuarantineImage(id: String, file: File, mimeType: Option[MimeType], meta: Map[String, String] = Map.empty) - (implicit logMarker: LogMarker): Future[S3Object] = + (implicit logMarker: LogMarker, instance: Instance): Future[S3Object] = storeImage(quarantineBucket, ImageIngestOperations.fileKeyFromId(id), file, mimeType, meta, overwrite = true) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala index f3016fddde1..1e57a3513af 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala @@ -19,6 +19,7 @@ object ImageStorageProps { val identifierMetadataKeyPrefix: String = "identifier!" val derivativeOfMediaIdsIdentifierKey: String = "derivative-of-media-ids" val replacesMediaIdIdentifierKey: String = "replaces-media-id" + val isFeedUploadMetadataKey = "is-feed-upload" } trait ImageStorage { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala index e477a3762d2..acaf5503db6 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala @@ -4,12 +4,11 @@ import com.gu.mediaservice.lib.aws.S3 import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} import com.gu.mediaservice.model.MimeType -import org.slf4j.LoggerFactory import software.amazon.awssdk.services.s3.model.{DeleteObjectRequest, HeadObjectRequest, ListObjectsV2Request} import java.io.File -import scala.jdk.CollectionConverters._ import scala.concurrent.Future +import scala.jdk.CollectionConverters._ // TODO: If deleteObject fails - we should be catching the errors here to avoid them bubbling to the application class S3ImageStorage(config: CommonConfig) extends S3(config) with ImageStorage with GridLogging { @@ -44,6 +43,7 @@ class S3ImageStorage(config: CommonConfig) extends S3(config) with ImageStorage val files = client.listObjectsV2( ListObjectsV2Request.builder().bucket(bucket).prefix(id).build() ).contents().asScala.toList + logger.info(s"Found ${files.size} files to delete in folder $id") files.foreach(file => client.deleteObject( DeleteObjectRequest.builder().bucket(bucket).key(file.key()).build() )) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala index 14bd411fa7b..4d321e82ca4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala @@ -2,6 +2,7 @@ package com.gu.mediaservice.lib.auth import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.model.Instance import play.api.mvc.{RequestHeader, Result} sealed trait Tier @@ -26,11 +27,11 @@ object ApiAccessor extends ArgoHelpers { ApiAccessor(name, tier) } - def hasAccess(apiKey: ApiAccessor, request: RequestHeader, services: Services): Boolean = apiKey.tier match { + def hasAccess(apiKey: ApiAccessor, request: RequestHeader, services: Services)(implicit instance: Instance): Boolean = apiKey.tier match { case Internal => true case ReadOnly => request.method == "GET" case Syndication => { - val isMediaApiRequest = request.uri.startsWith(services.apiBaseUri) // TODO check this! + val isMediaApiRequest = request.uri.startsWith(services.apiBaseUri(instance)) // TODO check this! request.method == "GET" && isMediaApiRequest && request.path.startsWith("/images") } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala index 0f008241bfd..2bd89378de8 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala @@ -2,6 +2,7 @@ package com.gu.mediaservice.lib.auth import com.gu.mediaservice.lib.BaseStore import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.model.Instance import software.amazon.awssdk.services.s3.model.ListObjectsV2Request import scala.jdk.CollectionConverters._ @@ -10,9 +11,9 @@ import scala.concurrent.ExecutionContext class KeyStore(bucket: String, config: CommonConfig)(implicit ec: ExecutionContext) extends BaseStore[String, ApiAccessor](bucket, config)(ec) { - def lookupIdentity(key: String): Option[ApiAccessor] = store.get().get(key) + def lookupIdentity(key: String)(implicit instance: Instance): Option[ApiAccessor] = store.get().get(instance.id + "/" + key) - def findKey(prefix: String): Option[String] = s3.syncFindKey(bucket, prefix) + def findKey(prefix: String)(implicit instance: Instance): Option[String] = s3.syncFindKey(bucket, prefix) def update(): Unit = { store.set(fetchAll) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala index 0815e6fb3ed..a480591147d 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala @@ -2,13 +2,14 @@ package com.gu.mediaservice.lib.aws import com.gu.mediaservice.lib.aws.DynamoDB.{deleteExpr, jsonWithNullAsEmptyString, setExpr} import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime import play.api.libs.json._ import software.amazon.awssdk.enhanced.dynamodb._ import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument import software.amazon.awssdk.enhanced.dynamodb.model.{BatchGetItemEnhancedRequest, ReadBatch} import software.amazon.awssdk.services.dynamodb.DynamoDbClient -import software.amazon.awssdk.services.dynamodb.model.{UpdateItemRequest, AttributeValue => AttributeValueV2, QueryRequest => QueryRequestV2, ReturnValue => ReturnValueV2} +import software.amazon.awssdk.services.dynamodb.model.{UpdateItemRequest, AttributeValue => AttributeValueV2, KeysAndAttributes => KeysAndAttributesV2, QueryRequest => QueryRequestV2, ReturnValue => ReturnValueV2} import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters._ @@ -23,22 +24,28 @@ object NoItemFound extends Throwable("item not found") * @tparam T The type of this table */ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Option[String] = None) extends GridLogging { + private val IdKey = "id" + private val InstanceKey = "instance" + lazy val dynamo: DynamoDbEnhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(client).build() lazy val tableSchema = TableSchema.documentSchemaBuilder() - .addIndexPartitionKey(TableMetadata.primaryIndexName(), IdKey, AttributeValueType.S) + .addIndexPartitionKey(TableMetadata.primaryIndexName(), InstanceKey, AttributeValueType.S) + .addIndexSortKey(TableMetadata.primaryIndexName(), IdKey, AttributeValueType.S) .attributeConverterProviders(AttributeConverterProvider.defaultProvider()) .build() lazy val table = dynamo.table(tableName, tableSchema) - private val IdKey = "id" - - private def itemKey(key: String) = Key.builder().partitionValue(key).build() + private def itemKey(key: String)(implicit instance: Instance) = + Key.builder() + .partitionValue(instance.id) + .sortValue(key) + .build() - def get(id: String)(implicit ex: ExecutionContext): Future[JsObject] = Future { + def get(id: String)(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { table.getItem(itemKey(id)) } flatMap docOrNotFound map asJsObject - private def get(id: String, attribute: String)(implicit ex: ExecutionContext): Future[EnhancedDocument] = Future { + private def get(id: String, attribute: String)(implicit ex: ExecutionContext, instance: Instance): Future[EnhancedDocument] = Future { Option(table.getItem(itemKey(id))).flatMap(doc => Option.when(doc.isPresent(attribute))(doc)) } flatMap { case Some(doc) => Future.successful(doc) @@ -52,22 +59,25 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op } } - def removeKey(id: String, key: String)(implicit ex: ExecutionContext) = Future{ + def removeKey(id: String, key: String)(implicit ex: ExecutionContext, instance: Instance) = Future{ update(id, DynamoDB.removeExpr(key, lastModifiedKey)) } - def deleteItem(id: String)(implicit ex: ExecutionContext): Future[Unit] = Future { + def deleteItem(id: String)(implicit ex: ExecutionContext, instance: Instance): Future[Unit] = Future { table.deleteItem( - Key.builder().partitionValue(id).build() + Key.builder(). + partitionValue(instance.id). + sortValue(id). + build() ) } def booleanGet(id: String, key: String) - (implicit ex: ExecutionContext): Future[Boolean] = { + (implicit ex: ExecutionContext, instance: Instance): Future[Boolean] = { get(id, key).map(_.getBoolean(key).booleanValue()) } def booleanSet(id: String, key: String, value: Boolean) - (implicit ex: ExecutionContext): Future[JsObject] = Future { + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { update( id, DynamoDB.setExpr(key, lastModifiedKey), @@ -76,26 +86,34 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op } def booleanSetOrRemove(id: String, key: String, value: Boolean) - (implicit ex: ExecutionContext): Future[JsObject] = + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = if (value) booleanSet(id, key, value) else removeKey(id, key) - def stringSet(id: String, key: String, value: String)(implicit ex: ExecutionContext): Future[JsObject] = Future { + def stringSet(id: String, key: String, value: String)(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { update(id, DynamoDB.setExpr(key, lastModifiedKey), AttributeValueV2.fromS(value)) } def setGet(id: String, key: String) - (implicit ex: ExecutionContext): Future[Set[String]] = { + (implicit ex: ExecutionContext, instance: Instance): Future[Set[String]] = { get(id, key).map(_.getStringSet(key).asScala.toSet) } - def setAdd(id: String, key: String, value: List[String])(implicit ex: ExecutionContext): Future[JsObject] = Future { + def setAdd(id: String, key: String, value: List[String])(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { update(id, DynamoDB.addExpr(key, lastModifiedKey), AttributeValueV2.fromSs(value.asJava)) } - def batchGet(ids: List[String], attributeKey: String)(implicit ex: ExecutionContext, rjs: Reads[T]): Future[Map[String, T]] = { + def batchGet(ids: List[String], attributeKey: String)(implicit ex: ExecutionContext, rjs: Reads[T], instance: Instance): Future[Map[String, T]] = { val chunks = - ids.grouped(100).toList.zipWithIndex + ids.map(k => ( + AttributeValueV2.builder() + .s(instance.id) + .build(), + AttributeValueV2.builder() + .s(k) + .build() + )) + .grouped(100).toList.zipWithIndex Future .traverse(chunks) { case (chunk, idx) => @@ -109,7 +127,8 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op chunk.foreach { id => readBatchBuilder.addGetItem( Key.builder() - .partitionValue(id) + .partitionValue(id._1) + .sortValue(id._2) .build() ) } @@ -148,7 +167,7 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op // We cannot update, so make sure you send over the WHOLE document def jsonAdd(id: String, key: String, value: Map[String, JsValue]) - (implicit ex: ExecutionContext): Future[JsObject] = Future { + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { update( id, setExpr(key, lastModifiedKey), @@ -157,7 +176,7 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op } def setDelete(id: String, key: String, value: String) - (implicit ex: ExecutionContext): Future[JsObject] = Future { + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { update(id, deleteExpr(key, lastModifiedKey), AttributeValueV2.fromSs(List(value).asJava)) } @@ -165,7 +184,7 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op indexName: String, keyName: String, key: String - )(implicit ex: ExecutionContext): Future[List[String]] = + )(implicit ex: ExecutionContext, instance: Instance): Future[List[String]] = Future { val response = @@ -173,9 +192,13 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op QueryRequestV2.builder() .tableName(tableName) .indexName(indexName) - .keyConditionExpression(s"$keyName = :key") + .keyConditionExpression(s"instance = :instance AND $keyName = :key") .expressionAttributeValues( Map( + ":instance" -> + AttributeValueV2.builder() + .s(instance.id) + .build(), ":key" -> AttributeValueV2.builder() .s(key) @@ -191,23 +214,26 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op } } - private def updateRequestBuilder(id: String, expression: String) = { + private def updateRequestBuilder(id: String, expression: String)(implicit instance: Instance) = { UpdateItemRequest.builder() - .key(Map(IdKey -> AttributeValueV2.fromS(id)).asJava) + .key(Map( + InstanceKey -> AttributeValueV2.fromS(instance.id), + IdKey -> AttributeValueV2.fromS(id) + ).asJava) .updateExpression(expression) .returnValues(ReturnValueV2.ALL_NEW) .tableName(tableName) } - def update(id: String, expression: String, attribute: AttributeValueV2): JsObject = { + def update(id: String, expression: String, attribute: AttributeValueV2)(implicit instance: Instance): JsObject = { update(id, expression, Map(":value" -> attribute)) } - def update(id: String, expression: String): JsObject = { + def update(id: String, expression: String)(implicit instance: Instance): JsObject = { update(id, expression, Map.empty[String, AttributeValueV2]) } - private def update(id: String, expression: String, baseValuesMap: Map[String, AttributeValueV2]) = { + private def update(id: String, expression: String, baseValuesMap: Map[String, AttributeValueV2])(implicit instance: Instance) = { val valuesMap = lastModifiedKey.fold(baseValuesMap)(key => baseValuesMap ++ Map(s":${key}" -> AttributeValueV2.fromS(DateTime.now().toString))) val updateRequest = updateRequestBuilder(id, expression) .expressionAttributeValues(valuesMap.asJava) @@ -218,7 +244,7 @@ class DynamoDB[T](client: DynamoDbClient, tableName: String, lastModifiedKey: Op } def asJsObject(doc: EnhancedDocument): JsObject = - jsonWithNullAsEmptyString(Json.parse(doc.toJson)).as[JsObject] - IdKey + jsonWithNullAsEmptyString(Json.parse(doc.toJson)).as[JsObject] - IdKey - InstanceKey } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala index f706b9e2137..efe5a287eca 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala @@ -10,7 +10,7 @@ import java.nio.file.{Files, Path} import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.CollectionHasAsScala -case class EmbedderMessage(imageId: String, fileType: String, s3Bucket: String, s3Key: String) +case class EmbedderMessage(imageId: String, fileType: String, s3Bucket: String, s3Key: String, instance: String) object EmbedderMessage { implicit val format: OFormat[EmbedderMessage] = Json.format[EmbedderMessage] diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala index bcc1e0a1093..3f86055d4e5 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala @@ -13,6 +13,7 @@ import com.gu.mediaservice.model.usage.UsageNotice import net.logstash.logback.marker.{LogstashMarker, Markers} import play.api.libs.json.{JodaWrites, Json, Writes} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime import java.net.URI @@ -36,12 +37,13 @@ class Kinesis(config: KinesisSenderConfig) extends GridLogging{ val partitionKey = UUID.randomUUID().toString implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val iw: Writes[Instance] = Json.writes[Instance] implicit val unw: Writes[UsageNotice] = Json.writes[UsageNotice] val payload = JsonByteArrayUtil.toByteArray(message) val markers: LogstashMarker = message.toLogMarker.and(Markers.append("compressed-size", payload.length)) - logger.info(markers, "Publishing message to kinesis") + logger.info(markers, s"Publishing message to kinesis: ${config.streamName}") val data = ByteBuffer.wrap(payload) val request = PutRecordRequest.builder() diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala index 2b3cd2a09fa..c8c264a2e5c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala @@ -7,17 +7,17 @@ import org.joda.time.{DateTime, DateTimeZone} import software.amazon.awssdk.core.ResponseInputStream import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.s3.{S3Client, S3Configuration} -import software.amazon.awssdk.services.s3.model.{GetObjectRequest, GetObjectResponse, HeadObjectRequest, HeadObjectResponse, ListObjectsV2Request, NoSuchKeyException, PutObjectRequest} +import software.amazon.awssdk.services.s3.model._ import software.amazon.awssdk.services.s3.presigner.S3Presigner import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.{S3Client, S3Configuration} import java.io.File -import java.net.URI +import java.net.{URI, URL} import java.nio.charset.StandardCharsets import java.time.Duration -import scala.jdk.CollectionConverters._ import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ case class S3Object(uri: URI, size: Long, metadata: S3Metadata) @@ -103,6 +103,28 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with req.url().toExternalForm } + def signUrlTony(bucket: Bucket, url: URI, expiration: DateTime = cachableExpiration()): URL = { + // get path and remove leading `/` + val key: Key = url.getPath.drop(1) + + val nowMillis = System.currentTimeMillis() + val targetExpirationMillis = expiration.getMillis + val remainingSeconds = Math.max(1, (targetExpirationMillis - nowMillis) / 1000) + + val getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + + val getObjectPresignRequest = GetObjectPresignRequest.builder() + .getObjectRequest(getObjectRequest) + .signatureDuration(Duration.ofSeconds(remainingSeconds)) + .build() + + val req = presigner.presignGetObject(getObjectPresignRequest) + req.url() + } + def getObject(bucket: Bucket, url: URI): ResponseInputStream[GetObjectResponse]= { // get path and remove leading `/` val key: Key = url.getPath.drop(1) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala index a7fa790454d..e84bef4d164 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala @@ -6,7 +6,7 @@ import com.gu.mediaservice.model.leases.MediaLease import com.gu.mediaservice.model.usage.UsageNotice import org.joda.time.{DateTime, DateTimeZone} import play.api.libs.functional.syntax.toFunctionalBuilderOps -import play.api.libs.json.{JodaReads, JodaWrites, Json, OWrites, Reads, Writes, __} +import play.api.libs.json.{JodaReads, JodaWrites, Json, OFormat, OWrites, Reads, Writes, __} // TODO MRB: replace this with the simple Kinesis class once we migrate off SNS class ThrallMessageSender(config: KinesisSenderConfig) { @@ -34,6 +34,7 @@ object BulkIndexRequest { object UpdateMessage extends GridLogging { implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val instanceFormats: OFormat[Instance] = Json.format[Instance] implicit val unw: OWrites[UsageNotice] = Json.writes[UsageNotice] implicit val unr: Reads[UsageNotice] = Json.reads[UsageNotice] implicit val writes: OWrites[UpdateMessage] = Json.writes[UpdateMessage] @@ -60,7 +61,8 @@ object UpdateMessage extends GridLogging { (__ \ "leases").readNullable[Seq[MediaLease]] ~ (__ \ "syndicationRights").readNullable[SyndicationRights] ~ (__ \ "bulkIndexRequest").readNullable[BulkIndexRequest] ~ - (__ \ "usageId").readNullable[String] + (__ \ "usageId").readNullable[String] ~ + (__ \ "instance").read[Instance] )(UpdateMessage.apply _) } @@ -80,7 +82,8 @@ case class UpdateMessage( leases: Option[Seq[MediaLease]] = None, syndicationRights: Option[SyndicationRights] = None, bulkIndexRequest: Option[BulkIndexRequest] = None, - usageId: Option[String] = None + usageId: Option[String] = None, + instance: Instance ) extends LogMarker { override def markerContents = { val message = Json.stringify(Json.toJson(this)) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala index eca1152ad26..70167400d49 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala @@ -34,6 +34,8 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui val localLogShipping: Boolean = sys.env.getOrElse("LOCAL_LOG_SHIPPING", "false").toBoolean + val thrallAppName = stringOpt("thrall.kinesis.app.name").getOrElse("thrall") + val thrallLowPriorityAppName = stringOpt("thrall.kinesis.lowPriority.app.name").getOrElse("thrall-low-priority") val thrallKinesisStream = string("thrall.kinesis.stream.name") val thrallKinesisLowPriorityStream = string("thrall.kinesis.lowPriorityStream.name") @@ -66,7 +68,9 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui val maybeBucketForUIUploads: Option[String] = maybeQuarantineBucket orElse maybeIngestBucket - val maybeUploadLimitInBytes: Option[Int] = intOpt("upload.limit.mb").map(_ * 1_000_000) + val maybeUploadLimitInBytes: Option[Int] = intOpt("upload.limit.mb").map(_ * 1024 * 1024) + + val instancesEndpoint: String = string("instance.service.instances") // Note: had to make these lazy to avoid init order problems ;_; val domainRoot: String = string("domain.root") @@ -75,8 +79,7 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui val corsAllowedOrigins: Set[String] = getStringSet("security.cors.allowedOrigins") - private val singleHostUrl: String = string("single.host.url") - val services = new SingleHostServices(singleHostUrl) + val services = new SingleHostServices(domainRoot) /** * Load in a list of domain metadata specifications from configuration. For example: @@ -119,6 +122,9 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui val recordDownloadAsUsage: Boolean = boolean("image.record.download") val shortenDownloadFilename: Boolean = boolean("image.download.shorten") + val myInstancesEndpoint: String = string("instance.service.my") + + val usageEventsQueueName: String = string("usageEvents.queue.name") /** * Load in a list of external staff photographers, internal staff photographers, contracted photographers, @@ -252,4 +258,5 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui private def missing(key: String, type_ : String): Nothing = sys.error(s"Required $type_ configuration property missing: $key") + } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala index f39a097786b..475a6f2bbc3 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala @@ -17,14 +17,11 @@ class CommonConfigWithElastic(resources: GridConfigResources) extends CommonConf includeDenseVectorMappings = booleanOpt("es.includeDenseVectorMappings").getOrElse(true) ) - private val persistenceIdentifier = string("persistence.identifier") val persistenceIdentifiers = NonEmptyList( - persistenceIdentifier, ImageStorageProps.derivativeOfMediaIdsIdentifierKey, ImageStorageProps.replacesMediaIdIdentifierKey ) val queriableIdentifiers = Seq( - persistenceIdentifier, ImageStorageProps.derivativeOfMediaIdsIdentifierKey, ) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala new file mode 100644 index 00000000000..45da79a9c95 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala @@ -0,0 +1,13 @@ +package com.gu.mediaservice.lib.config + +import com.gu.mediaservice.model.Instance +import play.api.mvc.RequestHeader + +trait InstanceForRequest { + + def instanceOf(request: RequestHeader): Instance = { + // TODO some sort of filter supplied attribute + Instance(request.host.split("\\.").head) + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala index 8bf7b282a5a..8a912f28181 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala @@ -1,76 +1,82 @@ package com.gu.mediaservice.lib.config +import com.gu.mediaservice.model.Instance + trait Services { - def kahunaBaseUri: String + def kahunaBaseUri(instance: Instance): String - def apiBaseUri: String + def apiBaseUri(instance: Instance): String - def loaderBaseUri: String + def loaderBaseUri(instance: Instance): String - def projectionBaseUri: String + def projectionBaseUri(instance: Instance): String - def cropperBaseUri: String + def cropperBaseUri(instance: Instance): String - def metadataBaseUri: String + def metadataBaseUri(instance: Instance): String - def imgopsBaseUri: String + def imgopsBaseUri(instance: Instance): String - def usageBaseUri: String + def usageBaseUri(instance: Instance): String - def collectionsBaseUri: String + def collectionsBaseUri(instance: Instance): String - def leasesBaseUri: String + def leasesBaseUri(instance: Instance): String - def authBaseUri: String + def authBaseUri(instance: Instance): String + def authBaseInstanceUri(instance: Instance): String - def thrallBaseUri: String + def thrallBaseUri(instance: Instance): String def guardianWitnessBaseUri: String - def corsAllowedDomains: Set[String] + def corsAllowedDomains(instance: Instance): Set[String] def redirectUriParam: String def redirectUriPlaceholder: String - def loginUriTemplate: String - + def loginUriTemplate(instance: Instance): String } -protected class SingleHostServices(val rootUrl: String) extends Services { - val kahunaBaseUri: String = rootUrl +protected class SingleHostServices(val domain: String) extends Services { + override def kahunaBaseUri(instance: Instance): String = vhostServiceName("", instance) - val apiBaseUri: String = subpathedServiceBaseUri("media-api") + override def apiBaseUri(instance: Instance): String = vhostServiceName("media-api", instance) - val loaderBaseUri: String = subpathedServiceBaseUri("image-loader") + override def loaderBaseUri(instance: Instance): String = vhostServiceName("image-loader", instance) - val projectionBaseUri: String = loaderBaseUri + override def projectionBaseUri(instance: Instance): String = vhostServiceName("projection", instance) - val cropperBaseUri: String = subpathedServiceBaseUri("cropper") + override def cropperBaseUri(instance: Instance): String = vhostServiceName("cropper", instance) - val metadataBaseUri: String = subpathedServiceBaseUri("metadata-editor") + override def metadataBaseUri(instance: Instance): String = vhostServiceName("metadata-editor", instance) - val imgopsBaseUri: String = subpathedServiceBaseUri("imgproxy") + override def imgopsBaseUri(instance: Instance): String= vhostServiceName("imgproxy", instance) - val usageBaseUri: String =subpathedServiceBaseUri("usage") + override def usageBaseUri(instance: Instance): String = vhostServiceName("usage", instance) - val collectionsBaseUri: String = subpathedServiceBaseUri("collections") + override def collectionsBaseUri(instance: Instance): String = vhostServiceName("collections", instance) - val leasesBaseUri: String = subpathedServiceBaseUri("leases") + override def leasesBaseUri(instance: Instance): String = vhostServiceName("leases", instance) - val authBaseUri: String = subpathedServiceBaseUri("auth") + override def authBaseUri(instance: Instance): String = s"https://$domain/auth" + override def authBaseInstanceUri(instance: Instance): String = vhostServiceName("auth", instance) - val thrallBaseUri: String = subpathedServiceBaseUri("thrall") + override def thrallBaseUri(instance: Instance): String = vhostServiceName("thrall", instance) val guardianWitnessBaseUri: String = "https://n0ticeapis.com" - val corsAllowedDomains: Set[String] = Set(kahunaBaseUri, apiBaseUri, thrallBaseUri) + override def corsAllowedDomains(instance: Instance): Set[String] = Set(kahunaBaseUri(instance), apiBaseUri(instance), thrallBaseUri(instance)) val redirectUriParam = "redirectUri" val redirectUriPlaceholder = s"{?$redirectUriParam}" - val loginUriTemplate = s"$authBaseUri/login$redirectUriPlaceholder" + def loginUriTemplate(instance: Instance): String = s"${authBaseUri(instance)}/login$redirectUriPlaceholder" - private def subpathedServiceBaseUri(serviceName: String): String = s"$rootUrl/$serviceName" + private def vhostServiceName(serviceName: String, instance: Instance): String = { + val vhost = instance.id + s"https://$vhost.$domain" + (if (serviceName.nonEmpty) "/" + serviceName else "") + } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala index 17321133d91..afa97cfc8db 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala @@ -26,15 +26,9 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { def url: String - def imagesCurrentAlias: String - def imagesMigrationAlias: String - lazy val imagesHistoricalAlias: String = "Images_Historical" - protected val imagesIndexPrefix = "images" protected val imageType = "image" - val initialImagesIndex = "images" - def shards: Int def replicas: Int def includeDenseVectorMappings: Boolean @@ -53,12 +47,12 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } //TODO: this function should fail and cause healthcheck fails - def ensureIndexExistsAndAliasAssigned(): Unit = { - logger.info(s"Checking alias $imagesCurrentAlias is assigned to index…") - val indexForCurrentAlias = Await.result(getIndexForAlias(imagesCurrentAlias), tenSeconds) + def ensureIndexExistsAndAliasAssigned(alias: String, index: String): Unit = { + logger.info(s"Checking alias $alias is assigned to index $index") + val indexForCurrentAlias = Await.result(getIndexForAlias(alias), tenSeconds) if (indexForCurrentAlias.isEmpty) { - createIndexIfMissing(initialImagesIndex) - assignAliasTo(initialImagesIndex, imagesCurrentAlias) + createIndexIfMissing(index) + assignAliasTo(index, alias) waitUntilHealthy() } } @@ -73,9 +67,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } def healthCheck(): Future[Boolean] = { - implicit val logMarker: MarkerMap = MarkerMap() - val request = search(imagesCurrentAlias) limit 0 - executeAndLog(request, "Healthcheck").map { _ => true}.recover { case _ => false} + Future.successful(true) // TODO reimplement } case class IndexWithAliases(name: String, aliases: Seq[String]) @@ -88,7 +80,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { }) } - def countImages(indexName: String = imagesCurrentAlias): Future[ElasticSearchImageCounts] = { + def countImages(indexName: String): Future[ElasticSearchImageCounts] = { implicit val logMarker: MarkerMap = MarkerMap() val queryCatCount = catCount(indexName) // document count only of index including live documents, not deleted documents which have not yet been removed by the merge process val queryImageSearch = search(indexName) trackTotalHits true limit 0 // hits that match the query defined in the request @@ -189,7 +181,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } } - def changeAliasTo(newIndex: String, oldIndex: String, alias: String = imagesCurrentAlias): Unit = { + def changeAliasTo(newIndex: String, oldIndex: String, alias: String): Unit = { logger.info(s"Assigning alias $alias to $newIndex") val aliasActionResponse = Await.result(client.execute { aliases( diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala index b52577ab2d5..95b063f4b5a 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala @@ -73,7 +73,8 @@ object MappingTest { lastModified = Some(imageModified), identifiers = Map("id1" -> "value1"), uploadInfo = UploadInfo( - filename = Some("filename.jpg") + filename = Some("filename.jpg"), + isFeedUpload = Some(true) ), source = testAsset, thumbnail = Some(Asset( diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala index f2fd652f869..4472412a0a0 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala @@ -271,7 +271,8 @@ object Mappings { )) def uploadInfoMapping(name: String): ObjectField = nonDynamicObjectField(name).copy(properties = Seq( - keywordField("filename") + keywordField("filename"), + booleanField("isFeedUpload"), )) def usageReference(name: String): ObjectField = { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala index 0622115303e..f0bfcb54c74 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala @@ -1,12 +1,14 @@ package com.gu.mediaservice.lib.elasticsearch +import com.gu.mediaservice.lib.instances.InstancesClient +import com.gu.mediaservice.model.Instance import org.apache.pekko.actor.Scheduler -import com.sksamuel.elastic4s.Index +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference import scala.concurrent.Await -import scala.concurrent.duration.DurationInt import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.{Duration, DurationInt, SECONDS} sealed trait MigrationStatus @@ -36,12 +38,24 @@ object MigrationStatusProvider { trait MigrationStatusProvider { self: ElasticSearchClient => + def elasticSearchConfig: ElasticSearchConfig + + def imagesCurrentAlias(instance: Instance): String = instance.id + "_" + elasticSearchConfig.aliases.current + def imagesMigrationAlias(instance: Instance): String = instance.id + "_" + elasticSearchConfig.aliases.migration + def imagesHistoricalAlias(instance: Instance): String = instance.id + "_" + "Images_Historical" + def scheduler: Scheduler - private val migrationStatusRef = new AtomicReference[MigrationStatus](fetchMigrationStatus(bubbleErrors = true)) + def instancesClient: InstancesClient - private def fetchMigrationStatus(bubbleErrors: Boolean): MigrationStatus = { - val statusFuture = getIndexForAlias(imagesMigrationAlias) + private val migrationStatues: ConcurrentHashMap[String, AtomicReference[MigrationStatus]] = new ConcurrentHashMap() + + private def migrationStatusRef(instance: Instance): AtomicReference[MigrationStatus] = { + migrationStatues.getOrDefault(instance.id, new AtomicReference(fetchMigrationStatus(bubbleErrors = true, instance = instance))) + } + + private def fetchMigrationStatus(bubbleErrors: Boolean, instance: Instance): MigrationStatus = { + val statusFuture = getIndexForAlias(imagesMigrationAlias(instance)) .map { case Some(index) if index.aliases.contains(MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) => CompletionPreview(index.name) case Some(index) if index.aliases.contains(MigrationStatusProvider.PAUSED_ALIAS) => Paused(index.name) @@ -54,30 +68,32 @@ trait MigrationStatusProvider { } catch { case e if !bubbleErrors => logger.error("Failed to get name of index for ongoing migration", e) - StatusRefreshError(cause = e, preErrorStatus = migrationStatusRef.get()) + StatusRefreshError(cause = e, preErrorStatus = migrationStatusRef(instance).get) } } - private def refreshMigrationStatus(): Unit = { - migrationStatusRef.set( - fetchMigrationStatus(bubbleErrors = false) - ) + private def refreshMigrationStatus(instance: Instance): Unit = { + migrationStatues.put(instance.id, new AtomicReference(fetchMigrationStatus(bubbleErrors = false, instance = instance))) } private val migrationStatusRefresher = scheduler.scheduleAtFixedRate( initialDelay = 0.seconds, interval = 5.seconds - ) { () => refreshMigrationStatus() } + ) { () => { + val instances = Await.result(instancesClient.getInstances(), Duration(10, SECONDS)) + instances.foreach(refreshMigrationStatus) + } + } - def migrationStatus: MigrationStatus = migrationStatusRef.get() - def migrationIsInProgress: Boolean = migrationStatus.isInstanceOf[InProgress] - def refreshAndRetrieveMigrationStatus(): MigrationStatus = { - refreshMigrationStatus() - migrationStatus + def migrationStatus(implicit instance: Instance): MigrationStatus = migrationStatusRef(instance).get() + def migrationIsInProgress(implicit instance: Instance): Boolean = migrationStatus.isInstanceOf[InProgress] + def refreshAndRetrieveMigrationStatus(instance: Instance): MigrationStatus = { + refreshMigrationStatus(instance) + migrationStatus(instance) } - def migrationStatusRefresherHealth: Option[String] = { - migrationStatusRef.get() match { + def migrationStatusRefresherHealth(implicit instance: Instance): Option[String] = { + migrationStatusRef(instance).get match { case StatusRefreshError(_, _) => Some("Could not determine status of migration") case _ => None } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala index 56cc1a2eec0..d8dc2e76fbb 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala @@ -18,7 +18,7 @@ trait ReapableEligibility extends Provider{ val persistenceIdentifiers: NonEmptyList[String] // typically from config private def moreThanTwentyDaysOld = - filters.date("uploadTime", None, Some(DateTime.now().minusDays(20))).getOrElse(matchAllQuery()) + filters.date("uploadTime", None, Some(DateTime.now().minusDays(ReapableEligibility.ReapableAfterMoreThanDaysOld))).getOrElse(matchAllQuery()) private lazy val persistedQueries = filters.or( PersistedQueries.hasCrops, @@ -35,8 +35,22 @@ trait ReapableEligibility extends Provider{ PersistedQueries.isInPersistedCollection(maybePersistOnlyTheseCollections) ) + private def isFeedUpload = + filters.boolTerm("uploadInfo.isFeedUpload", value = true) + def query: Query = filters.and( moreThanTwentyDaysOld, + isFeedUpload, + filters.not(persistedQueries) + ) + + def preview: Query = filters.and( + isFeedUpload, filters.not(persistedQueries) ) + +} + +object ReapableEligibility { + val ReapableAfterMoreThanDaysOld: Int = 20 } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala new file mode 100644 index 00000000000..6b4265ee654 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala @@ -0,0 +1,97 @@ +package com.gu.mediaservice.lib.events + +import org.apache.pekko.actor.{Actor, ActorSystem, Props} +import org.apache.pekko.pattern.ask +import org.apache.pekko.util.Timeout +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance +import org.joda.time.DateTime +import play.api.inject.ApplicationLifecycle +import play.api.libs.json.{JodaWrites, Json, OWrites} +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.SendMessageRequest + +import scala.concurrent.duration.DurationInt +import scala.util.Random + +class UsageEvents(actorSystem: ActorSystem, applicationLifecycle: ApplicationLifecycle, sqsClient: SqsClient, queueUrl: String) { + + private val random = new Random() + private val usageEventsActor = actorSystem.actorOf(UsageEventsActor.props(sqsClient, queueUrl), s"usageeventsactor-${random.alphanumeric.take(8).mkString}") + + applicationLifecycle.addStopHook(() => (usageEventsActor ? UsageEventsActor.Shutdown)(Timeout(5.seconds))) + + def successfulIngestFromQueue(instance: Instance, image: String, filesize: Long): Unit = { + usageEventsActor ! UsageEvent(`type` = "imageIngest", instance = instance.id, image = Some(image), filesize = Some(filesize)) + } + + def prepareUpload(instance: Instance, image: String, apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "prepareUpload", instance = instance.id, image = Some(image), apiKey = apiKey, user = user) + } + + def uploadImage(instance: Instance, image: String, filesize: Long, apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "imageUpload", instance = instance.id, image = Some(image), filesize = Some(filesize), apiKey = apiKey, user = user) + } + + def downloadOriginal(instance: Instance, image: String, filesize: Option[Long], apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "downloadOriginal", instance = instance.id, image = Some(image), filesize = filesize, apiKey = apiKey, user = user) + } + + def softDelete(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "softDelete", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def unsoftDelete(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "unsoftDelete", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def deleteImage(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "deleteImage", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def hardDeleteImage(instance: Instance, image: String) = { + usageEventsActor ! UsageEvent(`type` = "hardDeleteImage", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def apiKeyUsed(instance: Instance, apiKey: String) = { + usageEventsActor ! UsageEvent(`type` = "apiKeyUsed", instance = instance.id, apiKey = Some(apiKey)) + } + + def userAuthed(instance: Instance, user: String) = { + usageEventsActor ! UsageEvent(`type` = "userAuthed", instance = instance.id, user = Some(user)) + } +} + +case class UsageEvent(`type`: String, instance: String, + image: Option[String] = None, + filesize: Option[Long] = None, + date: DateTime = DateTime.now, + apiKey: Option[String] = None, + user: Option[String] = None) + +object UsageEvent extends JodaWrites { + implicit val uew: OWrites[UsageEvent] = Json.writes[UsageEvent] +} + + +object UsageEventsActor { + def props(sqsClient: SqsClient, queueUrl: String): Props = + Props(new UsageEventsActor(sqsClient, queueUrl)) + + final case object Shutdown +} + + +private class UsageEventsActor(sqsClient: SqsClient, queueUrl: String) extends Actor with GridLogging { + override def receive: Receive = { + case usageEvent: UsageEvent => + logger.info("Got usageEvent: " + usageEvent) + send(usageEvent) + } + + private def send(usageEvent: UsageEvent): Unit = { + import play.api.libs.json._ + sqsClient.sendMessage(SendMessageRequest.builder.queueUrl(queueUrl).messageBody(Json.stringify(Json.toJson(usageEvent))).build) + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala new file mode 100644 index 00000000000..8f37d67710a --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala @@ -0,0 +1,42 @@ +package com.gu.mediaservice.lib.instances + +import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.model.Instance +import com.typesafe.scalalogging.StrictLogging +import play.api.libs.json.{Json, Reads} +import play.api.libs.ws.{WSClient, WSResponse} + +import scala.concurrent.{ExecutionContext, Future} + +trait Instances extends StrictLogging { + def config: CommonConfig + + def wsClient: WSClient + + def getInstances()(implicit ec: ExecutionContext): Future[Seq[Instance]] = { + wsClient.url(config.instancesEndpoint).get().map { r => + handleInstancesResponse(r) + } + } + + def getMyInstances(owner: String)(implicit ec: ExecutionContext): Future[Seq[Instance]] = { + wsClient.url(config.myInstancesEndpoint).withQueryStringParameters("owner" -> owner).get().map { r => + handleInstancesResponse(r) + } + } + + private def handleInstancesResponse(r: WSResponse): Seq[Instance] = { + r.status match { + case 200 => + implicit val ir: Reads[Instance] = Json.reads[Instance] + Json.parse(r.body).as[Seq[Instance]] + case 404 => + logger.warn("Got 404 status for instances call; returning no permissions") + Seq.empty + case _ => + logger.error("Got non 200 status for instances call: " + r.status) + throw new RuntimeException("Could not load instances") + } + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala new file mode 100644 index 00000000000..ada7fc7df04 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala @@ -0,0 +1,6 @@ +package com.gu.mediaservice.lib.instances + +import com.gu.mediaservice.lib.config.CommonConfig +import play.api.libs.ws.WSClient + +class InstancesClient(val config: CommonConfig, val wsClient: WSClient) extends Instances diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala index 78310cf7b70..4227060fed4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala @@ -1,7 +1,7 @@ package com.gu.mediaservice.lib.metadata import com.gu.mediaservice.lib.config.CommonConfig -import com.gu.mediaservice.model.ImageStatusRecord +import com.gu.mediaservice.model.{ImageStatusRecord, Instance} import org.scanamo._ import org.scanamo.syntax._ import org.scanamo.generic.auto._ @@ -14,8 +14,8 @@ class SoftDeletedMetadataTable(config: CommonConfig) { private val softDeletedMetadataTable = Table[ImageStatusRecord](config.softDeletedMetadataTable) - def getStatus(imageId: String)(implicit ex: ExecutionContext) = { - ScanamoAsync(client).exec(softDeletedMetadataTable.get("id" === imageId)) + def getStatus(imageId: String)(implicit ex: ExecutionContext, instance: Instance) = { + ScanamoAsync(client).exec(softDeletedMetadataTable.get("id" === imageId and "instance" === instance.id)) } def setStatus(imageStatus: ImageStatusRecord)(implicit ex: ExecutionContext) = { @@ -27,18 +27,22 @@ class SoftDeletedMetadataTable(config: CommonConfig) { else ScanamoAsync(client).exec(softDeletedMetadataTable.putAll(imageStatuses)) } - def clearStatuses(imageIds: Set[String])(implicit ex: ExecutionContext) = { + def clearStatuses(imageIds: Set[String])(implicit ex: ExecutionContext, instance: Instance) = { if (imageIds.isEmpty) Future.successful(List.empty) - else ScanamoAsync(client).exec(softDeletedMetadataTable.deleteAll("id" in imageIds)) - } - - def updateStatus(imageId: String, isDeleted: Boolean)(implicit ex: ExecutionContext) = { + else { + Future.sequence(imageIds.map { id => + // Scanomo batch can't do composite keys? DSL is too confusing + ScanamoAsync(client).exec(softDeletedMetadataTable.delete("id" === id and "instance" === instance.id)) + }).map(_ => List.empty) + } } + + def updateStatus(imageId: String, isDeleted: Boolean)(implicit ex: ExecutionContext, instance: Instance) = { val updateExpression = set("isDeleted", isDeleted) ScanamoAsync(client).exec( softDeletedMetadataTable .when(attributeExists("id")) .update( - key = "id" === imageId, + "id" === imageId and "instance" === instance.id, update = updateExpression ) ) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala index eff2b51188a..85a8ef5fe1b 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala @@ -2,11 +2,13 @@ package com.gu.mediaservice.model import java.net.{URI, URLEncoder} import com.gu.mediaservice.lib.argo.model.{Action, EmbeddedEntity} +import com.gu.mediaservice.lib.config.InstanceForRequest import org.joda.time.DateTime import play.api.libs.json._ import play.api.libs.json.JodaReads._ import play.api.libs.json.JodaWrites._ import play.api.libs.functional.syntax._ +import play.api.mvc.Request case class Edits( @@ -55,7 +57,7 @@ object Edits { } trait EditsResponse { - val metadataBaseUri: String + val metadataBaseUri: Instance => String type ArchivedEntity = EmbeddedEntity[Boolean] type SetEntity = EmbeddedEntity[Seq[EmbeddedEntity[String]]] @@ -63,11 +65,11 @@ trait EditsResponse { type UsageRightsEntity = EmbeddedEntity[UsageRights] type PhotoshootEntity = EmbeddedEntity[Photoshoot] - def editsEmbeddedEntity(id: String, edits: Edits) = + def editsEmbeddedEntity(id: String, edits: Edits)(implicit instance: Instance) = EmbeddedEntity(entityUri(id), Some(Json.toJson(edits)(editsEntity(id)))) // the types are in the arguments because of a whining scala compiler - def editsEntity(id: String): Writes[Edits] = ( + def editsEntity(id: String)(implicit instance: Instance): Writes[Edits] = ( (__ \ Edits.Archived).write[ArchivedEntity].contramap(archivedEntity(id, _: Boolean)) ~ (__ \ Edits.Labels).write[SetEntity].contramap(setEntity(id, "labels", _: List[String])) ~ (__ \ Edits.Metadata).write[MetadataEntity].contramap(metadataEntity(id, _: ImageMetadata)) ~ @@ -76,31 +78,31 @@ trait EditsResponse { (__ \ Edits.LastModified).writeNullable[DateTime] )(unlift(Edits.unapply)) - def photoshootEntity(id: String, photoshoot: Option[Photoshoot]): PhotoshootEntity = + def photoshootEntity(id: String, photoshoot: Option[Photoshoot])(implicit instance: Instance): PhotoshootEntity = EmbeddedEntity(entityUri(id, "/photoshoot"), photoshoot) - def archivedEntity(id: String, a: Boolean): ArchivedEntity = + def archivedEntity(id: String, a: Boolean)(implicit instance: Instance): ArchivedEntity = EmbeddedEntity(entityUri(id, "/archived"), Some(a)) - def metadataEntity(id: String, m: ImageMetadata): MetadataEntity = + def metadataEntity(id: String, m: ImageMetadata)(implicit instance: Instance): MetadataEntity = EmbeddedEntity(entityUri(id, "/metadata"), Some(m), actions = List( Action("set-from-usage-rights", entityUri(id, "/metadata/set-from-usage-rights"), "POST") )) - def usageRightsEntity(id: String, u: Option[UsageRights]): UsageRightsEntity = + def usageRightsEntity(id: String, u: Option[UsageRights])(implicit instance: Instance): UsageRightsEntity = u.map(i => EmbeddedEntity(entityUri(id, "/usage-rights"), Some(i))) .getOrElse(EmbeddedEntity(entityUri(id, "/usage-rights"), None)) - def setEntity(id: String, setName: String, labels: List[String]): SetEntity = + def setEntity(id: String, setName: String, labels: List[String])(implicit instance: Instance): SetEntity = EmbeddedEntity(entityUri(id, s"/$setName"), Some(labels.map(setUnitEntity(id, setName, _)))) - def setUnitEntity(id: String, setName: String, name: String): EmbeddedEntity[String] = + def setUnitEntity(id: String, setName: String, name: String)(implicit instance: Instance): EmbeddedEntity[String] = EmbeddedEntity(entityUri(id, s"/$setName/${URLEncoder.encode(name, "UTF-8")}"), Some(name)) - private def entityUri(id: String, endpoint: String = ""): URI = - URI.create(s"$metadataBaseUri/metadata/$id$endpoint") + private def entityUri(id: String, endpoint: String = "")(implicit instance: Instance): URI = + URI.create(s"${metadataBaseUri(instance)}/metadata/$id$endpoint") - def labelsUri(id: String) = entityUri(id, "/labels") + def labelsUri(id: String)(implicit instance: Instance) = entityUri(id, "/labels") - def metadataUri(id: String) = entityUri(id, "/metadata") + def metadataUri(id: String)(implicit instance: Instance) = entityUri(id, "/metadata") } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala index b58ebddb9f0..c942e813211 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala @@ -6,7 +6,8 @@ case class ImageStatusRecord( id: String, deletedBy: String, deleteTime: String, - isDeleted: Boolean + isDeleted: Boolean, + instance: String ) object ImageStatusRecord { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala new file mode 100644 index 00000000000..ba631d5bf0c --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala @@ -0,0 +1,5 @@ +package com.gu.mediaservice.model + +case class Instance(id: String) { + override def toString: String = id // TODO need to visit all the urls builders an make them use .id +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala index a80012a5d8c..5a4344ef13f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala @@ -25,13 +25,13 @@ sealed trait InternalThrallMessage extends ThrallMessage {} sealed trait MigrationMessage extends InternalThrallMessage {} -case class MigrateImageMessage(id: String, maybeImageWithVersion: Either[String, (Image, Long)]) extends MigrationMessage +case class MigrateImageMessage(id: String, maybeImageWithVersion: Either[String, (Image, Long)], instance: Instance) extends MigrationMessage object MigrateImageMessage { - def apply(imageId: String, maybeProjection: Option[Image], maybeVersion: Option[Long]): MigrateImageMessage = (maybeProjection, maybeVersion) match { - case (Some(projection), Some(version)) => MigrateImageMessage(imageId, scala.Right((projection, version))) - case (None, _) => MigrateImageMessage(imageId, Left("There was no projection returned")) - case _ => MigrateImageMessage(imageId, Left("There was no version returned")) + def apply(imageId: String, maybeProjection: Option[Image], maybeVersion: Option[Long], instance: Instance): MigrateImageMessage = (maybeProjection, maybeVersion) match { + case (Some(projection), Some(version)) => MigrateImageMessage(imageId, scala.Right((projection, version)), instance) + case (None, _) => MigrateImageMessage(imageId, Left("There was no projection returned"), instance) + case _ => MigrateImageMessage(imageId, Left("There was no version returned"), instance) } } @@ -45,6 +45,7 @@ sealed trait ExternalThrallMessage extends ThrallMessage { implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites val id: String val lastModified: DateTime + val instance: Instance def toJson: JsValue = Json.toJson(this)(ExternalThrallMessage.writes) override def markerContents: Map[String, Any] = { @@ -61,6 +62,8 @@ object ExternalThrallMessage{ implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val instanceMessageFormat: OFormat[Instance] = Json.format[Instance] + implicit val usageNoticeFormat: OFormat[UsageNotice] = Json.format[UsageNotice] implicit val replaceImageLeasesMessageFormat: OFormat[ReplaceImageLeasesMessage] = Json.format[ReplaceImageLeasesMessage] @@ -86,58 +89,63 @@ object ExternalThrallMessage{ implicit val completeMigrationMessage: OFormat[CompleteMigrationMessage] = Json.format[CompleteMigrationMessage] implicit val upsertFromProjectionMessage: OFormat[UpsertFromProjectionMessage] = Json.format[UpsertFromProjectionMessage] + implicit val createInstanceMessage: OFormat[CreateInstanceMessage] = Json.format[CreateInstanceMessage] + implicit val writes: OWrites[ExternalThrallMessage] = Json.writes[ExternalThrallMessage] implicit val reads: Reads[ExternalThrallMessage] = Json.reads[ExternalThrallMessage] } -case class ImageMessage(lastModified: DateTime, image: Image) extends ExternalThrallMessage { +case class ImageMessage(lastModified: DateTime, image: Image, instance: Instance) extends ExternalThrallMessage { override def additionalMarkers: () => Map[String, Any] = ()=> Map("fileName" -> image.source.file.toString) override val id: String = image.id } -case class DeleteImageMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteImageMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class SoftDeleteImageMessage(id: String, lastModified: DateTime, softDeletedMetadata: SoftDeletedMetadata) extends ExternalThrallMessage +case class SoftDeleteImageMessage(id: String, lastModified: DateTime, softDeletedMetadata: SoftDeletedMetadata, instance: Instance) extends ExternalThrallMessage -case class UnSoftDeleteImageMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class UnSoftDeleteImageMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class DeleteImageExportsMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteImageExportsMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class UpdateImageExportsMessage(id: String, lastModified: DateTime, crops: Seq[Crop]) extends ExternalThrallMessage +case class UpdateImageExportsMessage(id: String, lastModified: DateTime, crops: Seq[Crop], instance: Instance) extends ExternalThrallMessage -case class UpdateImageUserMetadataMessage(id: String, lastModified: DateTime, edits: Edits) extends ExternalThrallMessage +case class UpdateImageUserMetadataMessage(id: String, lastModified: DateTime, edits: Edits, instance: Instance) extends ExternalThrallMessage -case class UpdateImageUsagesMessage(id: String, lastModified: DateTime, usageNotice: UsageNotice) extends ExternalThrallMessage +case class UpdateImageUsagesMessage(id: String, lastModified: DateTime, usageNotice: UsageNotice, instance: Instance) extends ExternalThrallMessage -case class ReplaceImageLeasesMessage(id: String, lastModified: DateTime, leases: Seq[MediaLease]) extends ExternalThrallMessage +case class ReplaceImageLeasesMessage(id: String, lastModified: DateTime, leases: Seq[MediaLease], instance: Instance) extends ExternalThrallMessage -case class AddImageLeaseMessage(id: String, lastModified: DateTime, lease: MediaLease) extends ExternalThrallMessage +case class AddImageLeaseMessage(id: String, lastModified: DateTime, lease: MediaLease, instance: Instance) extends ExternalThrallMessage -case class RemoveImageLeaseMessage(id: String, lastModified: DateTime, leaseId: String) extends ExternalThrallMessage +case class RemoveImageLeaseMessage(id: String, lastModified: DateTime, leaseId: String, instance: Instance) extends ExternalThrallMessage -case class SetImageCollectionsMessage(id: String, lastModified: DateTime, collections: Seq[Collection]) extends ExternalThrallMessage +case class SetImageCollectionsMessage(id: String, lastModified: DateTime, collections: Seq[Collection], instance: Instance) extends ExternalThrallMessage -case class DeleteSingleUsageMessage(id: String, lastModified: DateTime, usageId: String) extends ExternalThrallMessage +case class DeleteSingleUsageMessage(id: String, lastModified: DateTime, usageId: String, instance: Instance) extends ExternalThrallMessage -case class DeleteUsagesMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteUsagesMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class UpdateUsageStatusMessage(id: String, usageNotice: UsageNotice, lastModified: DateTime) extends ExternalThrallMessage +case class UpdateUsageStatusMessage(id: String, usageNotice: UsageNotice, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class UpdateEmbeddingMessage(id: String, lastModified: DateTime, embedding: Embedding) extends ExternalThrallMessage +case class UpdateEmbeddingMessage(id: String, lastModified: DateTime, embedding: Embedding, instance: Instance) extends ExternalThrallMessage object DeleteUsagesMessage { implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + + implicit val instanceMessageFormat: OFormat[Instance] = Json.format[Instance] + implicit val what: OFormat[DeleteUsagesMessage] = Json.format[DeleteUsagesMessage] } -case class UpdateImageSyndicationMetadataMessage(id: String, lastModified: DateTime, maybeSyndicationRights: Option[SyndicationRights]) extends ExternalThrallMessage +case class UpdateImageSyndicationMetadataMessage(id: String, lastModified: DateTime, maybeSyndicationRights: Option[SyndicationRights], instance: Instance) extends ExternalThrallMessage -case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTime, edits: Edits) extends ExternalThrallMessage +case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTime, edits: Edits, instance: Instance) extends ExternalThrallMessage /** * Message to start a new 'migration' (for re-index, re-ingestion etc.) @@ -146,7 +154,8 @@ case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTi */ case class CreateMigrationIndexMessage( migrationStart: DateTime, - gitHash: String + gitHash: String, + instance: Instance ) extends ExternalThrallMessage { val id: String = "N/A" val lastModified: DateTime = migrationStart @@ -155,8 +164,10 @@ case class CreateMigrationIndexMessage( s"images_${migrationStart.toString(DateTimeFormat.forPattern("yyyy-MM-dd_HH-mm-ss").withZoneUTC())}_${gitHash.take(7)}" } -case class UpsertFromProjectionMessage(id: String, image: Image, lastModified: DateTime) extends ExternalThrallMessage +case class UpsertFromProjectionMessage(id: String, image: Image, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class CompleteMigrationMessage(lastModified: DateTime) extends ExternalThrallMessage { +case class CompleteMigrationMessage(lastModified: DateTime, instance: Instance) extends ExternalThrallMessage { val id: String = "N/A" } + +case class CreateInstanceMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala index 5b8beb65455..b4c10e0c51c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala @@ -2,7 +2,7 @@ package com.gu.mediaservice.model import play.api.libs.json.{Json, OWrites, Reads} -case class UploadInfo(filename: Option[String] = None) +case class UploadInfo(filename: Option[String] = None, isFeedUpload: Option[Boolean] = None) object UploadInfo { implicit val jsonWrites: OWrites[UploadInfo] = Json.writes[UploadInfo] diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala index 05c0c2f133b..0eb91a2ca08 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala @@ -182,9 +182,9 @@ object Chargeable extends UsageRightsSpec { val defaultCost = Some(Pay) def name(commonConfig: CommonConfig) = "Chargeable supplied / on spec" def description(commonConfig: CommonConfig) = - s"Images acquired by or supplied to ${commonConfig.staffPhotographerOrganisation} that do not fit other categories in ${commonConfig.systemName} and " + + s"Images acquired or supplied that do not fit other categories in ${commonConfig.systemName} and " + "therefore fees will be payable per use. Unless negotiated otherwise, fees should be based on " + - s"standard published ${commonConfig.staffPhotographerOrganisation} rates for stock and speculative images." + s"standard published rates for stock and speculative images." implicit val formats: Format[Chargeable] = UsageRights.subtypeFormat(Chargeable.category)(Json.format[Chargeable]) @@ -289,7 +289,7 @@ object Screengrab extends UsageRightsSpec { val defaultCost = Some(Free) def name(commonConfig: CommonConfig) = "Screengrab" def description(commonConfig: CommonConfig) = - s"Stills created by ${commonConfig.staffPhotographerOrganisation} from moving footage in television broadcasts usually in relation to " + + s"Stills created by us from moving footage in television broadcasts usually in relation to " + "breaking news stories." implicit val formats: Format[Screengrab] = diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala index 27abc9ec487..a1e57b0cd38 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala @@ -1,10 +1,11 @@ package com.gu.mediaservice.model.usage import com.gu.mediaservice.lib.formatting.printDateTime +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime import play.api.libs.json.{JodaWrites, JsArray, JsObject, Json} -case class UsageNotice(mediaId: String, usageJson: JsArray) { +case class UsageNotice(mediaId: String, usageJson: JsArray, instance: Instance) { def toJson = Json.obj( "id" -> mediaId, "data" -> usageJson, diff --git a/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala b/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala index a490f427f5c..c40ff2de17b 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala @@ -21,6 +21,7 @@ trait MessageSubjects { val DeleteSingleUsage = "delete-single-usage" val UpdateImageSyndicationMetadata = "update-image-syndication-metadata" val UpdateImagePhotoshootMetadata = "update-image-photoshoot-metadata" + val CreateInstance = "create-instance" } diff --git a/common-lib/src/test/resources/application.conf b/common-lib/src/test/resources/application.conf index fa78ccd9211..05d1db98e6b 100644 --- a/common-lib/src/test/resources/application.conf +++ b/common-lib/src/test/resources/application.conf @@ -3,7 +3,6 @@ grid.appName: "test" thrall.kinesis.stream.name: "not-used" thrall.kinesis.lowPriorityStream.name: "not-used" domain.root: "notused.example.com" -single.host.url: "notused.example.com" image.processors = [ "com.gu.mediaservice.lib.cleanup.GuardianMetadataCleaners", @@ -57,3 +56,8 @@ usageRightsConfigProvider = { suppliersCollectionExcl {} } } + +instance.service.my="" +instance.service.instances="" + +usageEvents.queue.name="" diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala index 0b505673211..f126f7be964 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala @@ -1,5 +1,6 @@ package com.gu.mediaservice.lib.aws +import com.gu.mediaservice.model.Instance import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers import play.api.libs.json.Json @@ -12,7 +13,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { describe("json to message and back") { // This is most interested for ensuring time zone correctness it ("should convert a message to json and back again") { - val m = UpdateMessage(subject = "test") + val m = UpdateMessage(subject = "test", instance = Instance("an-instance")) val j = Json.toJson(m).toString() val m2 = Json.parse(j).as[UpdateMessage] m2 shouldEqual m @@ -20,7 +21,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { it ("should convert a message from an external source which does not have last modified") { val subject = "test" - val j = s"""{"subject":"$subject"}""" + val j = s"""{"subject":"$subject", "instance": {"id": "an-instance"}}""" val m = Json.parse(j).as[UpdateMessage] m.lastModified.getZone.toString should be ("UTC") } @@ -28,7 +29,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { it ("should convert a message last modified with an offset timezone to UTC") { val now = DateTime.now(DateTimeZone.forOffsetHours(9)) val nowUtc = new DateTime(now.getMillis()).toDateTime(DateTimeZone.UTC) - val m = UpdateMessage(subject = "test", lastModified = now) + val m = UpdateMessage(subject = "test", lastModified = now, instance = Instance("an-instance")) val j = Json.toJson(m).toString() val m2 = Json.parse(j).as[UpdateMessage] m2 shouldEqual m.copy(lastModified = nowUtc) diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala index b78542f7deb..1ac9e3381ee 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala @@ -19,7 +19,7 @@ trait MetadataHelper { softDeletedMetadata = None, lastModified = None, identifiers = Map(), - uploadInfo = UploadInfo(), + uploadInfo = UploadInfo(isFeedUpload = Some(true)), source = Asset(URI.create("http://example.com/image.jpg"), Some(0), None, None, None, None), thumbnail = None, optimisedPng = None, diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala index c076bedb257..93cd5423c64 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala @@ -26,7 +26,7 @@ class JsonOrderingTest extends AnyFreeSpec with Matchers { uploadedBy = "Biden", softDeletedMetadata = None, lastModified = None, - uploadInfo = UploadInfo(None), + uploadInfo = UploadInfo(None, None), source = Asset(new URI("fileUri"), None, None, None), optimisedPng = None, originalUsageRights = Handout(None), diff --git a/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala index 44a8d4f169e..ee1ad991dd8 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala @@ -24,59 +24,59 @@ class ExternalThrallMessageTest extends AnyFreeSpec with Matchers with TableDriv "Make some JSON" - { "from an imageMessage" in { val image = ImageTest.createImage("hello") - val message = ImageMessage(nowUtc, image.copy(uploadTime = nowUtc)) + val message = ImageMessage(nowUtc, image.copy(uploadTime = nowUtc), instance = Instance("an-instance")) //Manually set the image time, because the time zone data is lost in //conversion roundTrip(message) } "from a DeleteImageMessage" in { - val dim = DeleteImageMessage("hey", nowUtc) + val dim = DeleteImageMessage("hey", nowUtc, instance = Instance("an-instance")) roundTrip(dim) } "from a DeleteImageExportsMessage" in { - val diem = DeleteImageExportsMessage("carpe", nowUtc) + val diem = DeleteImageExportsMessage("carpe", nowUtc, instance = Instance("an-instance")) roundTrip(diem) } "from a UpdateImageExportsMessage" in { - val uiem = UpdateImageExportsMessage("id", nowUtc, Seq()) + val uiem = UpdateImageExportsMessage("id", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(uiem) } "from a UpdateImageUserMetadataMessage" in { - val msg = UpdateImageUserMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata())) + val msg = UpdateImageUserMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata()), instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImageUsagesMessage" in { - val msg = UpdateImageUsagesMessage("hello", nowUtc, UsageNotice("hello", JsArray())) + val msg = UpdateImageUsagesMessage("hello", nowUtc, UsageNotice("hello", JsArray(), instance = Instance("an-instance")), instance = Instance("an-instance")) roundTrip(msg) } "from a ReplaceImageLeasesMessage" in { - val msg = ReplaceImageLeasesMessage("hello", nowUtc, Seq()) + val msg = ReplaceImageLeasesMessage("hello", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(msg) } "from a AddImageLeaseMessage" in { - val msg = AddImageLeaseMessage("hello", nowUtc, MediaLease(None, None, notes = None, mediaId = "")) + val msg = AddImageLeaseMessage("hello", nowUtc, MediaLease(None, None, notes = None, mediaId = ""), instance = Instance("an-instance")) roundTrip(msg) } "from a RemoveImageLeaseMessage" in { - val msg = RemoveImageLeaseMessage("hello", nowUtc, "bye") + val msg = RemoveImageLeaseMessage("hello", nowUtc, "bye", instance = Instance("an-instance")) roundTrip(msg) } "from a SetImageCollectionsMessage" in { - val msg = SetImageCollectionsMessage("hello", nowUtc, Seq()) + val msg = SetImageCollectionsMessage("hello", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(msg) } "from a DeleteUsagesMessage" in { - val msg = DeleteUsagesMessage("hello", nowUtc) + val msg = DeleteUsagesMessage("hello", nowUtc, instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImageSyndicationMetadataMessage" in { - val msg = UpdateImageSyndicationMetadataMessage("hello", nowUtc, None) + val msg = UpdateImageSyndicationMetadataMessage("hello", nowUtc, None, instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImagePhotoshootMetadataMessage" in { - val msg = UpdateImagePhotoshootMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata())) + val msg = UpdateImagePhotoshootMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata()), instance = Instance("an-instance")) roundTrip(msg) } } diff --git a/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala index 4b5e1c7effa..31fc0b492dc 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala @@ -143,7 +143,7 @@ object ImageTest { softDeletedMetadata = None, lastModified = None, identifiers = Map.empty, - uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg")), + uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg"), isFeedUpload = Some(true)), source = Asset( file = new URI(s"https://file/$id"), size = Some(1L), diff --git a/cropper/app/CropperComponents.scala b/cropper/app/CropperComponents.scala index 291049feeaa..b05381cb06d 100644 --- a/cropper/app/CropperComponents.scala +++ b/cropper/app/CropperComponents.scala @@ -13,7 +13,7 @@ class CropperComponents(context: Context) extends GridComponents(context, new Cr val store = new CropStore(config) val imageOperations = new ImageOperations(context.environment.rootPath.getAbsolutePath) - val crops = new Crops(config, store, imageOperations) + val crops = new Crops(config, store, imageOperations, config.imageBucket) val notifications = new Notifications(config) private val gridClient = GridClient(config.services, config.services.cropperBaseUri)(wsClient) diff --git a/cropper/app/controllers/CropperController.scala b/cropper/app/controllers/CropperController.scala index 0d6a1211dfa..7c784f62320 100644 --- a/cropper/app/controllers/CropperController.scala +++ b/cropper/app/controllers/CropperController.scala @@ -9,6 +9,7 @@ import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.Permissions.{DeleteCropsOrUsages, PrincipalFilter} import com.gu.mediaservice.lib.auth._ import com.gu.mediaservice.lib.aws.UpdateMessage +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.imaging.ExportResult import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter @@ -32,22 +33,24 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no override val controllerComponents: ControllerComponents, authorisation: Authorisation, gridClient: GridClient)(implicit val ec: ExecutionContext) - extends BaseController with MessageSubjects with ArgoHelpers with MediaApiUrls { + extends BaseController with MessageSubjects with ArgoHelpers with MediaApiUrls with InstanceForRequest { // Stupid name clash between Argo and Play import com.gu.mediaservice.lib.argo.model.{Action => ArgoAction} val AuthenticatedAndAuthorisedToDeleteCrops = auth andThen authorisation.CommonActionFilters.authorisedForDeleteCropsOrUsages - val indexResponse = { + private def indexResponse(instance: Instance) = { val indexData = Map("description" -> "This is the Cropper Service") val indexLinks = List( - Link("crop", s"${config.rootUri}/crops") + Link("crop", s"${config.rootUri(instance)}/crops") ) respond(indexData, indexLinks) } - def index = auth { indexResponse } + def index = auth { request => + indexResponse(instanceOf(request)) + } def addExport = auth.async(parse.json) { httpRequest => httpRequest.body.validate[ExportRequest] map { exportRequest => @@ -59,10 +62,10 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no val user = httpRequest.user val onBehalfOfPrincipal = auth.getOnBehalfOfPrincipal(user) - executeRequest(exportRequest, user, onBehalfOfPrincipal).map { case (imageId, export) => + executeRequest(exportRequest, user, onBehalfOfPrincipal, httpRequest).map { case (imageId, export) => val cropJson = Json.toJson(export).as[JsObject] - val updateMessage = UpdateMessage(subject = UpdateImageExports, id = Some(imageId), crops = Some(Seq(export))) + val updateMessage = UpdateMessage(subject = UpdateImageExports, id = Some(imageId), crops = Some(Seq(export)), instance = instanceOf(httpRequest)) notifications.publish(updateMessage) Ok(cropJson).as(ArgoMediaType) @@ -77,9 +80,6 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no case InvalidImage => logger.error(logMarker, InvalidImage.getMessage) respondError(BadRequest, "invalid-image", InvalidImage.getMessage) - case MissingSecureSourceUrl => - logger.error(logMarker, MissingSecureSourceUrl.getMessage) - respondError(BadRequest, "no-source-image", MissingSecureSourceUrl.getMessage) case InvalidCropRequest => logger.error(logMarker, InvalidCropRequest.getMessage) respondError(BadRequest, "invalid-crop", InvalidCropRequest.getMessage) @@ -103,10 +103,10 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no private val canDeleteCrops: PrincipalFilter = authorisation.hasPermissionTo(DeleteCropsOrUsages) - private def downloadExportLink(imageId: String, exportId: String, width: Int) = Link(s"crop-download-$exportId-$width", s"${config.apiUri}/images/$imageId/export/$exportId/asset/$width/download") + private def downloadExportLink(imageId: String, exportId: String, width: Int)(implicit instance: Instance) = Link(s"crop-download-$exportId-$width", s"${config.apiUri(instance)}/images/$imageId/export/$exportId/asset/$width/download") def getCrops(id: String) = auth.async { httpRequest => - + implicit val instance: Instance = instanceOf(httpRequest) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "getCrops", "requestId" -> RequestLoggingFilter.getRequestId(httpRequest), @@ -117,7 +117,7 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no store.listCrops(id) map (_.toList) map { crops => val deleteCropsAction = - ArgoAction("delete-crops", URI.create(s"${config.rootUri}/crops/$id"), "DELETE") + ArgoAction("delete-crops", URI.create(s"${config.rootUri(instance)}/crops/$id"), "DELETE") lazy val cropDownloadLinks = for { crop <- crops @@ -145,13 +145,14 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no } def deleteCrops(id: String) = AuthenticatedAndAuthorisedToDeleteCrops.async { httpRequest => + implicit val instance: Instance = instanceOf(httpRequest) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "deleteCrops", "requestId" -> RequestLoggingFilter.getRequestId(httpRequest), "imageId" -> id ) store.deleteCrops(id).map { _ => - val updateMessage = UpdateMessage(subject = DeleteImageExports, id = Some(id)) + val updateMessage = UpdateMessage(subject = DeleteImageExports, id = Some(id), instance = instance) notifications.publish(updateMessage) Accepted } recover { @@ -160,11 +161,12 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no } def executeRequest( - exportRequest: ExportRequest, user: Principal, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal + exportRequest: ExportRequest, user: Principal, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal, + request: Authentication.Request[JsValue] )(implicit logMarker: LogMarker): Future[(String, Crop)] = { - + implicit val instance: Instance = instanceOf(request) for { - _ <- verify(isMediaApiImageUri(exportRequest.uri, config.apiUri), InvalidSource) + _ <- verify(isMediaApiImageUri(exportRequest.uri, config.apiUri(instance)), InvalidSource) apiImage <- fetchSourceFromApi(exportRequest.uri, onBehalfOfPrincipal) _ <- verify(apiImage.valid, InvalidImage) // Image should always have dimensions, but we want to safely extract the Option @@ -179,12 +181,12 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no specification = cropSpec ) markersWithCropDetails = logMarker ++ Map("imageId" -> apiImage.id, "cropId" -> Crop.getCropId(cropSpec.bounds)) - ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop)(markersWithCropDetails) + ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop)(markersWithCropDetails, instance) finalCrop = Crop.createFromCrop(crop, masterSizing, sizings) } yield (id, finalCrop) } - private def fetchSourceFromApi(uri: String, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal): Future[SourceImage] = { + private def fetchSourceFromApi(uri: String, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal)(implicit instance: Instance): Future[SourceImage] = { gridClient.getSourceImage(imageIdFrom(uri), onBehalfOfPrincipal) } diff --git a/cropper/app/lib/CropStore.scala b/cropper/app/lib/CropStore.scala index a81b141793c..749764337e7 100644 --- a/cropper/app/lib/CropStore.scala +++ b/cropper/app/lib/CropStore.scala @@ -26,8 +26,8 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS } } - def listCrops(id: String): Future[List[Crop]] = { - list(config.imgPublishingBucket, id).map { crops => + def listCrops(id: String)(implicit instance: Instance): Future[List[Crop]] = { + list(config.imgPublishingBucket, folderForImagesCrops(id, instance)).map { crops => // TODO crops layout want to be pull up crops.foldLeft(Map[String, Crop]()) { case (map, (s3Object)) => { val filename::containingFolder::_ = s3Object.uri.getPath.split("/").reverse.toList @@ -51,7 +51,7 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS sizing = Asset( - translateImgHost(s3Object.uri), + signedCropAssetUrl(s3Object.uri), Some(s3Object.size), objectMetadata.contentType, Some(dimensions), @@ -70,11 +70,20 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS } } - def deleteCrops(id: String)(implicit logMarker: LogMarker) = { - deleteFolder(config.imgPublishingBucket, id) + def deleteCrops(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = { + deleteFolder(config.imgPublishingBucket, folderForImagesCrops(id, instance)) } // FIXME: this doesn't really belong here def translateImgHost(uri: URI): URI = new URI("https", config.imgPublishingHost, uri.getPath, uri.getFragment) + + private def folderForImagesCrops(id: Bucket, instance: Instance) = { + instance.id + "/" + id + } + + private def signedCropAssetUrl(uri: URI): URI = { + signUrlTony(config.imgPublishingBucket, uri).toURI + } + } diff --git a/cropper/app/lib/CropperConfig.scala b/cropper/app/lib/CropperConfig.scala index 25f78a5dacc..96d043d60a7 100644 --- a/cropper/app/lib/CropperConfig.scala +++ b/cropper/app/lib/CropperConfig.scala @@ -1,11 +1,14 @@ package lib import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance import java.io.File class CropperConfig(resources: GridConfigResources) extends CommonConfig(resources) { + val imageBucket: String = string("s3.image.bucket") + val imgPublishingBucket = string("publishing.image.bucket") val canDownloadCrop: Boolean = boolean("canDownloadCrop") @@ -14,8 +17,8 @@ class CropperConfig(resources: GridConfigResources) extends CommonConfig(resourc // Note: work around CloudFormation not allowing optional parameters val imgPublishingSecureHost = stringOpt("publishing.image.secure.host").filterNot(_.isEmpty) - val rootUri = services.cropperBaseUri - val apiUri = services.apiBaseUri + val rootUri: Instance => String = services.cropperBaseUri + val apiUri: Instance => String = services.apiBaseUri val tempDir: File = new File(stringDefault("crop.output.tmp.dir", "/tmp")) diff --git a/cropper/app/lib/Crops.scala b/cropper/app/lib/Crops.scala index a38771aa000..6c2be74d21c 100644 --- a/cropper/app/lib/Crops.scala +++ b/cropper/app/lib/Crops.scala @@ -3,6 +3,7 @@ package lib import java.io.File import com.gu.mediaservice.lib.metadata.FileMetadataHelper import com.gu.mediaservice.lib.Files +import com.gu.mediaservice.lib.aws.S3 import com.gu.mediaservice.lib.imaging.{ExportResult, ImageOperations} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.model._ @@ -12,12 +13,11 @@ import scala.util.Try case object InvalidImage extends Exception("Invalid image cannot be cropped") case object MissingMimeType extends Exception("Missing mimeType from source API") -case object MissingSecureSourceUrl extends Exception("Missing secureUrl from source API") case object InvalidCropRequest extends Exception("Crop request invalid for image dimensions") case class MasterCrop(sizing: Future[Asset], file: File, dimensions: Dimensions, aspectRatio: Float) -class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations)(implicit ec: ExecutionContext) extends GridLogging { +class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations, imageBucket: String)(implicit ec: ExecutionContext) extends GridLogging { import Files._ private val cropQuality = 75d @@ -26,19 +26,21 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera // We don't overly care about output crop file sizes here, but prefer a fast output, so turn it right down. private val pngCropQuality = 1d - def outputFilename(source: SourceImage, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false): String = { + private val s3 = new S3(config) + + def outputFilename(source: SourceImage, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false)(implicit instance: Instance): String = { val masterString: String = if (isMaster) "master/" else "" - s"${source.id}/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" + instance.id + "/" + s"${source.id}/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" } - def createMasterCrop( + private def createMasterCrop( apiImage: SourceImage, sourceFile: File, crop: Crop, mediaType: MimeType, colourModel: Option[String], orientationMetadata: Option[OrientationMetadata], - )(implicit logMarker: LogMarker): Future[MasterCrop] = { + )(implicit logMarker: LogMarker, instance: Instance): Future[MasterCrop] = { Stopwatch.async(s"creating master crop for ${apiImage.id}") { val source = crop.specification @@ -65,7 +67,7 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera } } - def createCrops(sourceFile: File, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType)(implicit logMarker: LogMarker): Future[List[Asset]] = { + private def createCrops(sourceFile: File, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType)(implicit logMarker: LogMarker, instance: Instance): Future[List[Asset]] = { val quality = if (cropType == Png) pngCropQuality else cropQuality Stopwatch.async(s"creating crops for ${apiImage.id}") { @@ -89,7 +91,7 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera } } - def deleteCrops(id: String)(implicit logMarker: LogMarker): Future[Unit] = store.deleteCrops(id) + def deleteCrops(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = store.deleteCrops(id) private def dimensionsFromConfig(bounds: Bounds, aspectRatio: Float): List[Dimensions] = if (bounds.isPortrait) config.portraitCropSizingHeights.filter(_ <= bounds.height).map(h => Dimensions(math.round(h * aspectRatio), h)) @@ -106,14 +108,16 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera positiveCoords && strictlyPositiveSize && withinBounds } - def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker): Future[ExportResult] = { + def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker, instance: Instance): Future[ExportResult] = { val source = crop.specification val mimeType = apiImage.source.mimeType.getOrElse(throw MissingMimeType) - val secureUrl = apiImage.source.secureUrl.getOrElse(throw MissingSecureSourceUrl) + val secureFile = apiImage.source.file val colourType = apiImage.fileMetadata.colourModelInformation.getOrElse("colorType", "") val hasAlpha = apiImage.fileMetadata.colourModelInformation.get("hasAlpha").flatMap(a => Try(a.toBoolean).toOption).getOrElse(true) val cropType = Crops.cropType(mimeType, colourType, hasAlpha) + val secureUrl = s3.signUrlTony(imageBucket, secureFile) + Stopwatch.async(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { for { sourceFile <- tempFileFromURL(secureUrl, "cropSource", "", config.tempDir) diff --git a/cropper/test/lib/CropsTest.scala b/cropper/test/lib/CropsTest.scala index a0d0ba8a62c..e348b9a4a6e 100644 --- a/cropper/test/lib/CropsTest.scala +++ b/cropper/test/lib/CropsTest.scala @@ -9,6 +9,8 @@ import org.scalatestplus.mockito.MockitoSugar class CropsTest extends AnyFunSpec with Matchers with MockitoSugar { import scala.concurrent.ExecutionContext.Implicits.global + private implicit val instance: Instance = Instance(id = "an-instance") + it("should return JPEG when the input type is a JPEG") { Crops.cropType(Jpeg, "True Color", hasAlpha = false) shouldBe Jpeg Crops.cropType(Jpeg, "Monkey", hasAlpha = false) shouldBe Jpeg @@ -48,25 +50,26 @@ class CropsTest extends AnyFunSpec with Matchers with MockitoSugar { private val source: SourceImage = SourceImage("test", mock[Asset], valid = true, mock[ImageMetadata], mock[FileMetadata]) private val bounds: Bounds = Bounds(10, 20, 30, 40) private val outputWidth = 1234 + private val imageBucket = "crops-bucket" it("should should construct a correct address for a master jpg") { - val outputFilename = new Crops(config, store, imageOperations) + val outputFilename = new Crops(config, store, imageOperations, imageBucket) .outputFilename(source, bounds, outputWidth, Jpeg, isMaster = true) - outputFilename shouldBe "test/10_20_30_40/master/1234.jpg" + outputFilename shouldBe "an-instance/test/10_20_30_40/master/1234.jpg" } it("should should construct a correct address for a non-master jpg") { - val outputFilename = new Crops(config, store, imageOperations) + val outputFilename = new Crops(config, store, imageOperations, imageBucket) .outputFilename(source, bounds, outputWidth, Jpeg) - outputFilename shouldBe "test/10_20_30_40/1234.jpg" + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.jpg" } it("should should construct a correct address for a non-master tiff") { - val outputFilename = new Crops(config, store, imageOperations) + val outputFilename = new Crops(config, store, imageOperations, imageBucket) .outputFilename(source, bounds, outputWidth, Tiff) - outputFilename shouldBe "test/10_20_30_40/1234.tiff" + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.tiff" } it("should should construct a correct address for a non-master png") { - val outputFilename = new Crops(config, store, imageOperations) + val outputFilename = new Crops(config, store, imageOperations, imageBucket) .outputFilename(source, bounds, outputWidth, Png) - outputFilename shouldBe "test/10_20_30_40/1234.png" + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.png" } } diff --git a/dev/script/generate-config/service-config.js b/dev/script/generate-config/service-config.js index b5820bba78e..6e34288838c 100644 --- a/dev/script/generate-config/service-config.js +++ b/dev/script/generate-config/service-config.js @@ -175,9 +175,7 @@ function getKahunaConfig(config){ return stripMargin`${getCommonConfig(config)} |aws.region="${config.AWS_DEFAULT_REGION}" - |origin.full="images.media.${config.DOMAIN}" |origin.thumb="localstack.media.${config.DOMAIN}" - |origin.images="images.media.${config.DOMAIN}" |origin.crops="public.media.${config.DOMAIN}" |google.tracking.id="${config.google.tracking.id}" |links.feedbackForm="${config.links.feedbackForm}" @@ -210,7 +208,6 @@ function getMediaApiConfig(config) { |s3.thumb.bucket="${config.coreStackProps.ThumbBucket}" |s3.config.bucket="${config.coreStackProps.ConfigBucket}" |s3.usagemail.bucket="${config.coreStackProps.UsageMailBucket}" - |persistence.identifier="picdarUrn" |es6.url="${config.es6.url}" |es6.shards=${config.es6.shards} |es6.replicas=${config.es6.replicas} @@ -244,7 +241,6 @@ function getThrallConfig(config) { |s3.image.bucket="${config.coreStackProps.ImageBucket}" |s3.thumb.bucket="${config.coreStackProps.ThumbBucket}" |s3.reaper.bucket="${config.coreStackProps.ReaperBucket}" - |persistence.identifier="picdarUrn" |indexed.image.sns.topic.arn="${config.coreStackProps.IndexedImageTopic}" |es6.url="${config.es6.url}" |es6.shards=${config.es6.shards} diff --git a/docs/06-objects-of-interest/02-config.md b/docs/06-objects-of-interest/02-config.md index bbb968a096c..7b8d78d4524 100644 --- a/docs/06-objects-of-interest/02-config.md +++ b/docs/06-objects-of-interest/02-config.md @@ -86,12 +86,6 @@ Service-specific configs. These will override all other config files. Json Object Array [] - - persistence.identifier
Used by the reaper to retain images which have a particular identifier (e.g. picdarUrn for Guardian) - True - string - - persistence.onlyTheseCollections
Used by the reaper…
    diff --git a/image-loader/app/ImageLoaderComponents.scala b/image-loader/app/ImageLoaderComponents.scala index c5d088a8309..9d4a739b263 100644 --- a/image-loader/app/ImageLoaderComponents.scala +++ b/image-loader/app/ImageLoaderComponents.scala @@ -1,5 +1,5 @@ import com.gu.mediaservice.GridClient -import com.gu.mediaservice.lib.aws.{Bedrock, S3Vectors, SimpleSqsMessageConsumer, Embedder} +import com.gu.mediaservice.lib.aws.{Bedrock, Embedder, S3Vectors, SimpleSqsMessageConsumer} import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.GridLogging import com.gu.mediaservice.lib.play.GridComponents @@ -43,7 +43,7 @@ class ImageLoaderComponents(context: Context) extends GridComponents(context, ne val metrics = new ImageLoaderMetrics(config, actorSystem, applicationLifecycle) val controller = new ImageLoaderController( - auth, downloader, store, maybeIngestQueue, uploadStatusTable, notifications, config, uploader, quarantineUploader, projector, controllerComponents, gridClient, authorisation, metrics, applicationLifecycle) + auth, downloader, store, maybeIngestQueue, uploadStatusTable, config, uploader, quarantineUploader, projector, controllerComponents, gridClient, authorisation, metrics, usageEvents, wsClient, applicationLifecycle) val uploadStatusController = new UploadStatusController(auth, uploadStatusTable, config, controllerComponents, authorisation) val imageLoaderManagement = new ImageLoaderManagement(controllerComponents, buildInfo, controller.maybeIngestQueueAndProcessor) diff --git a/image-loader/app/controllers/ImageLoaderController.scala b/image-loader/app/controllers/ImageLoaderController.scala index cde5129fe82..2c85b621cba 100644 --- a/image-loader/app/controllers/ImageLoaderController.scala +++ b/image-loader/app/controllers/ImageLoaderController.scala @@ -9,31 +9,40 @@ import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.ImageIngestOperations.fileKeyFromId import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.Link -import com.gu.mediaservice.lib.auth.Authentication.OnBehalfOfPrincipal +import com.gu.mediaservice.lib.auth.Authentication.{MachinePrincipal, OnBehalfOfPrincipal, UserPrincipal} import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider import com.gu.mediaservice.lib.aws.{S3Ops, SimpleSqsMessageConsumer, SqsHelpers} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.formatting.printDateTime +import com.gu.mediaservice.lib.instances.Instances import com.gu.mediaservice.lib.logging.{FALLBACK, LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter import com.gu.mediaservice.lib.{DateTimeUtils, ImageIngestOperations, ImageStorageProps} -import com.gu.mediaservice.model.{UnsupportedMimeTypeException, UploadInfo} -import org.scanamo.{ConditionNotMet, ScanamoError} +import com.gu.mediaservice.model.{Instance, UnsupportedMimeTypeException, UploadInfo} import lib.FailureResponse.Response +import lib._ import lib.imaging.{MimeTypeDetection, NoSuchImageExistsInS3, UserImageLoaderException} import lib.storage.{ImageLoaderStore, S3FileDoesNotExistException} -import lib._ import model.upload.UploadRequest import model.{Projector, QuarantineUploader, S3FileExtractedMetadata, S3IngestObject, StatusType, UploadStatus, UploadStatusRecord, UploadStatusUri, Uploader} +import org.apache.pekko.Done +import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.Source import org.joda.time.{DateTime, DateTimeZone} +import org.scanamo.{ConditionNotMet, ScanamoError} import play.api.data.Form import play.api.data.Forms._ import play.api.inject.ApplicationLifecycle import play.api.libs.json.Json +import play.api.libs.ws.WSClient import play.api.mvc._ import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.cloudwatch.model.Dimension import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.{GetObjectRequest, HeadObjectRequest, NoSuchKeyException} +import software.amazon.awssdk.services.sqs.model.{Message => SQSMessage} import java.io.{File, FileOutputStream} import java.net.URI @@ -49,8 +58,7 @@ class ImageLoaderController(auth: Authentication, store: ImageLoaderStore, maybeIngestQueue: Option[SimpleSqsMessageConsumer], uploadStatusTable: UploadStatusTable, - notifications: Notifications, - config: ImageLoaderConfig, + val config: ImageLoaderConfig, uploader: Uploader, quarantineUploader: Option[QuarantineUploader], projector: Projector, @@ -58,9 +66,11 @@ class ImageLoaderController(auth: Authentication, gridClient: GridClient, authorisation: Authorisation, metrics: ImageLoaderMetrics, + usageEvents: UsageEvents, + val wsClient: WSClient, applicationLifecycle: ApplicationLifecycle) (implicit val ec: ExecutionContext, materializer: Materializer) - extends BaseController with ArgoHelpers with SqsHelpers { + extends BaseController with ArgoHelpers with SqsHelpers with InstanceForRequest with Instances { private val AuthenticatedAndAuthorised = auth andThen authorisation.CommonActionFilters.authorisedForUpload @@ -116,26 +126,27 @@ class ImageLoaderController(auth: Authentication, (ingestQueue, processor) } - private lazy val indexResponse: Result = { + private def indexResponse(instance: Instance): Result = { val indexData = Map("description" -> "This is the Loader Service") val indexLinks = List( - Link("prepare", s"${config.rootUri}/prepare"), - Link("uploadStatus", s"${config.rootUri}/uploadStatus/{id}"), - Link("uploadStatuses", s"${config.rootUri}/uploadStatuses"), - Link("load", s"${config.rootUri}/images{?uploadedBy,identifiers,uploadTime,filename}"), - Link("import", s"${config.rootUri}/imports{?uri,uploadedBy,identifiers,uploadTime,filename}") + Link("prepare", s"${config.rootUri(instance)}/prepare"), + Link("uploadStatus", s"${config.rootUri(instance)}/uploadStatus/{id}"), + Link("uploadStatuses", s"${config.rootUri(instance)}/uploadStatuses"), + Link("load", s"${config.rootUri(instance)}/images{?uploadedBy,identifiers,uploadTime,filename}"), + Link("import", s"${config.rootUri(instance)}/imports{?uri,uploadedBy,identifiers,uploadTime,filename}") ) respond(indexData, indexLinks) } - def index: Action[AnyContent] = AuthenticatedAndAuthorised { indexResponse } - - private def quarantineOrStoreImage(uploadRequest: UploadRequest)(implicit logMarker: LogMarker) = { - quarantineUploader.map(_.quarantineFile(uploadRequest)).getOrElse(for { uploadStatusUri <- uploader.storeFile(uploadRequest)} yield{uploadStatusUri.toJsObject}) + def index: Action[AnyContent] = AuthenticatedAndAuthorised { request => + indexResponse(instanceOf(request)) } - private def handleMessageFromIngestBucket(sqsMessage:SQSMessage)(basicLogMarker: LogMarker): Future[Unit] = Future[Future[Unit]]{ + private def quarantineOrStoreImage(uploadRequest: UploadRequest)(implicit logMarker: LogMarker, instance: Instance) = { + quarantineUploader.map(_.quarantineFile(uploadRequest)(instance)).getOrElse(for { uploadStatusUri <- uploader.storeFile(uploadRequest)} yield{uploadStatusUri.toJsObject}) + } + private def handleMessageFromIngestBucket(sqsMessage: SQSMessage)(basicLogMarker: LogMarker): Future[Unit] = { logger.info(basicLogMarker, sqsMessage.toString) extractS3KeyFromSqsMessage(sqsMessage) match { @@ -144,9 +155,22 @@ class ImageLoaderController(auth: Authentication, logger.error(basicLogMarker, s"Failed to parse s3 data from SQS message", exception) Future.unit case Success(key) => - val s3IngestObject = S3IngestObject(key, store)(basicLogMarker) + val pathComponents = key.split("/") + val instanceId = pathComponents.head + val path = pathComponents.drop(1).mkString("/") + logger.info(s"Instance and key: $instanceId / $path") + + val eventualIsValidInstanceId = getInstances().map { instances => + instances.map(_.id).contains(instanceId) + } - val isUiUpload = s3IngestObject.maybeMediaIdFromUiUpload.isDefined + eventualIsValidInstanceId.flatMap { isValidInstanceSpecificKey => + if (isValidInstanceSpecificKey) { + try { + implicit val instance: Instance = Instance(id = instanceId) + + val s3IngestObject = S3IngestObject(key, store)(basicLogMarker) + val isUiUpload = s3IngestObject.maybeMediaIdFromUiUpload.isDefined implicit val logMarker: LogMarker = basicLogMarker ++ Map( "uploadedBy" -> s3IngestObject.uploadedBy, @@ -160,51 +184,62 @@ class ImageLoaderController(auth: Authentication, Dimension.builder().name("IsUiUpload").value(isUiUpload.toString).build(), ) - val approximateReceiveCount = getApproximateReceiveCount(sqsMessage) - - if(config.maybeUploadLimitInBytes.exists(_ < s3IngestObject.contentLength)){ - val errorMessage = s"File size exceeds the maximum allowed size (${config.maybeUploadLimitInBytes.get / 1_000_000}MB). Moving to fail bucket." - logger.warn(logMarker, errorMessage) - store.moveObjectToFailedBucket(s3IngestObject.key) - s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => - uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do - imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) - ) - } - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - Future.unit - } - else if (approximateReceiveCount > 2) { - metrics.abandonedMessagesFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - val errorMessage = s"File processing has been attempted $approximateReceiveCount times. Moving to fail bucket." - logger.warn(logMarker, errorMessage) - store.moveObjectToFailedBucket(s3IngestObject.key) - s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => - uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do - imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) - ) - } - Future.unit - } else { - attemptToProcessIngestedFile(s3IngestObject, isUiUpload)(logMarker) map { digestedFile => - metrics.successfulIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.info(logMarker, s"Successfully processed image ${digestedFile.file.getName}") - store.deleteObjectFromIngestBucket(s3IngestObject.key) - } recover { - case _: UnsupportedMimeTypeException => - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.info(logMarker, s"Unsupported mime type. Moving straight to fail bucket.") - store.moveObjectToFailedBucket(s3IngestObject.key) - case t: Throwable => - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.error(logMarker, s"Failed to process file. Moving to fail bucket.", t) - store.moveObjectToFailedBucket(s3IngestObject.key) + val approximateReceiveCount = getApproximateReceiveCount(sqsMessage) + + if (config.maybeUploadLimitInBytes.exists(_ < s3IngestObject.contentLength)) { + val errorMessage = s"File size exceeds the maximum allowed size (${config.maybeUploadLimitInBytes.get / 1_000_000}MB). Moving to fail bucket." + logger.warn(logMarker, errorMessage) + store.moveObjectToFailedBucket(s3IngestObject.key) + s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => + uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do + imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) + ) + } + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + Future.unit + } + else if (approximateReceiveCount > 2) { + metrics.abandonedMessagesFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + val errorMessage = s"File processing has been attempted $approximateReceiveCount times. Moving to fail bucket." + logger.warn(logMarker, errorMessage) + store.moveObjectToFailedBucket(s3IngestObject.key) + s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => + uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do + imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) + ) + } + Future.unit + } else { + attemptToProcessIngestedFile(s3IngestObject, isUiUpload)(logMarker)(instance) map { digestedFile => + metrics.successfulIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + usageEvents.successfulIngestFromQueue(instance = instance, image = digestedFile.digest, filesize = s3IngestObject.contentLength ) + logger.info(logMarker, s"Successfully processed image ${digestedFile.file.getName}") + store.deleteObjectFromIngestBucket(s3IngestObject.key) + } recover { + case _: UnsupportedMimeTypeException => + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + logger.info(logMarker, s"Unsupported mime type. Moving straight to fail bucket.") + store.moveObjectToFailedBucket(s3IngestObject.key) + case t: Throwable => + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + logger.error(logMarker, s"Failed to process file. Moving to fail bucket.", t) + store.moveObjectToFailedBucket(s3IngestObject.key) + } + } + } + catch { + case t: Throwable => + logger.error("Uncaught throw:", t) + Future.unit + } + } else { + Future.unit } } } - }.flatten + } - private def attemptToProcessIngestedFile(s3IngestObject:S3IngestObject, isUiUpload: Boolean)(initialLogMarker:LogMarker): Future[DigestedFile] = { + private def attemptToProcessIngestedFile(s3IngestObject:S3IngestObject, isUiUpload: Boolean)(initialLogMarker:LogMarker)(implicit instance: Instance): Future[DigestedFile] = { logger.info(initialLogMarker, "Attempting to process file") val tempFile = createTempFile("s3IngestBucketFile")(initialLogMarker) @@ -218,12 +253,14 @@ class ImageLoaderController(auth: Authentication, "mediaId" -> digestedFile.digest ) + val filename = s3IngestObject.filename val futureUploadStatusUri = uploadDigestedFileToStore( digestedFileFuture = Future(digestedFile), uploadedBy = s3IngestObject.uploadedBy, identifiers = s3IngestObject.identifiers, uploadTime = Some(s3IngestObject.uploadTime.toString) , // upload time as iso string - uploader uses DateTimeUtils.fromValueOrNow - filename = Some(s3IngestObject.filename) + filename = Some(filename), + isFeedUpload = s3IngestObject.isFeedUpload, ) // under all circumstances, remove the temp files @@ -239,6 +276,8 @@ class ImageLoaderController(auth: Authentication, } def getPreSignedUploadUrlsAndTrack: Action[AnyContent] = AuthenticatedAndAuthorised.async { request => + implicit val instance: Instance = instanceOf(request) + val expiration = DateTimeUtils.now().plusHours(1) val signatureDuration = Duration.ofHours(1) @@ -249,6 +288,7 @@ class ImageLoaderController(auth: Authentication, Future.sequence( mediaIdToFilenameMap.map{case (mediaId, filename) => + logger.info(s"Preparing file upload for instance $instance: $mediaId / $filename") val preSignedUrl = store.generatePreSignedUploadUrl(filename, signatureDuration, uploadedBy, mediaId) @@ -261,9 +301,19 @@ class ImageLoaderController(auth: Authentication, StatusType.Prepared, errorMessage = None, expires = expiration.toEpochSecond, // TTL in case upload is never completed by client - )).map(_ => + instance = instance.id + )).map { _ => + val user = request.user match { + case u: UserPrincipal => u.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey) + case _ => None + } + val apiKey = request.user match { + case m: MachinePrincipal => Some(m.accessor.identity) + case _ => None + } + usageEvents.prepareUpload(instance = instance, image = mediaId, user = user, apiKey = apiKey) mediaId -> preSignedUrl - ) + } } ) .map(_.toMap) @@ -271,7 +321,7 @@ class ImageLoaderController(auth: Authentication, .map(Ok(_)) } - def loadImage(uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String]): Action[DigestedFile] = { + def loadImage(uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String]): Action[DigestedFile] = { val uploadTimeToRecord = DateTimeUtils.fromValueOrNow(uploadTime) val initialContext = MarkerMap( @@ -288,7 +338,8 @@ class ImageLoaderController(auth: Authentication, logger.info(initialContext, "body parsed") val bodyParser = DigestBodyParser.create(tempFile) - AuthenticatedAndAuthorised.async(bodyParser) { req => + AuthenticatedAndAuthorised.async(bodyParser) { req: Authentication.Request[DigestedFile] => + implicit val instance: Instance = instanceOf(req) val uploadedByToRecord = uploadedBy.getOrElse(Authentication.getIdentity(req.user)) implicit val context: LogMarker = @@ -299,23 +350,38 @@ class ImageLoaderController(auth: Authentication, val uploadStatus = if(config.maybeQuarantineBucket.isDefined) StatusType.Pending else StatusType.Completed val uploadExpiry = Instant.now.getEpochSecond + config.uploadStatusExpiry.toSeconds - val record = UploadStatusRecord(req.body.digest, filename, uploadedByToRecord, printDateTime(uploadTimeToRecord), identifiers, uploadStatus, None, uploadExpiry) + val record = UploadStatusRecord(req.body.digest, filename, uploadedByToRecord, printDateTime(uploadTimeToRecord), identifiers, uploadStatus, None, uploadExpiry, instance.id) + logger.info(s"Loading image for instance ${instance.id}: record ${record.id} / $filename") + val result = for { uploadRequest <- uploader.loadFile( req.body, uploadedByToRecord, identifiers.map(Json.parse(_).as[Map[String, String]]).getOrElse(Map.empty), uploadTimeToRecord, - filename.flatMap(_.trim.nonEmptyOpt) + filename.flatMap(_.trim.nonEmptyOpt), + instance, + isFeedUpload = false, ) _ <- uploadStatusTable.setStatus(record) - result <- quarantineOrStoreImage(uploadRequest) + + result <- quarantineOrStoreImage(uploadRequest)(context, instance) + } yield result result.onComplete( _ => Try { deleteTempFile(tempFile) } ) result map { r => val result = Accepted(r).as(ArgoMediaType) logger.info(context, "loadImage request end") + val user = req.user match { + case u: UserPrincipal => u.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey) + case _ => None + } + val apiKey = req.user match { + case m: MachinePrincipal => Some(m.accessor.identity) + case _ => None + } + usageEvents.uploadImage(instance = instance, image = req.body.digest, filesize = req.body.file.length(), apiKey = apiKey, user = user) result } recover { case NonFatal(e) => @@ -347,6 +413,7 @@ class ImageLoaderController(auth: Authentication, val bodyParser = DigestBodyParser.create(tempFile) AuthenticatedAndAuthorised.async(bodyParser) { req => + implicit val instance: Instance = instanceOf(req) val allIdentifiers = identifiers.map(Json.parse(_).as[Map[String, String]]).getOrElse(Map.empty) ++ Map( ImageStorageProps.derivativeOfMediaIdsIdentifierKey -> derivativeOfMediaIds @@ -372,12 +439,14 @@ class ImageLoaderController(auth: Authentication, // Fetch def projectImageBy(imageId: String): Action[AnyContent] = { + val initialContext = MarkerMap( "imageId" -> imageId, "requestType" -> "image-projection" ) val tempFile = createTempFile(s"projection-$imageId")(initialContext) auth.async { req => + implicit val instance: Instance = instanceOf(req) implicit val context: LogMarker = initialContext ++ Map( "requestId" -> RequestLoggingFilter.getRequestId(req) ) @@ -408,9 +477,11 @@ class ImageLoaderController(auth: Authentication, uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], - filename: Option[String] + filename: Option[String], ): Action[AnyContent] = { AuthenticatedAndAuthorised.async { request => + implicit val instance: Instance = instanceOf(request) + implicit val context: MarkerMap = MarkerMap( "requestType" -> "import-image", "key-tier" -> request.user.accessor.tier.toString, @@ -418,7 +489,7 @@ class ImageLoaderController(auth: Authentication, "requestId" -> RequestLoggingFilter.getRequestId(request) ) - logger.info(context, "importImage request start") + logger.info(context, "importImage request start for $uri into instance $instance") val tempFile = createTempFile("download") val digestedFileFuture = for { @@ -431,7 +502,8 @@ class ImageLoaderController(auth: Authentication, uploadedBy.getOrElse(Authentication.getIdentity(request.user)), identifiers.map(Json.parse(_).as[Map[String, String]]).getOrElse(Map.empty), uploadTime, - filename + filename, + isFeedUpload = false, ) // under all circumstances, remove the temp files @@ -458,8 +530,9 @@ class ImageLoaderController(auth: Authentication, uploadedBy: String, identifiers: Map[String, String], uploadTime: Option[String], - filename: Option[String] - )(implicit logMarker:LogMarker): Future[UploadStatusUri] = { + filename: Option[String], + isFeedUpload: Boolean + )(implicit logMarker:LogMarker, instance: Instance): Future[UploadStatusUri] = { for { digestedFile <- digestedFileFuture @@ -473,6 +546,8 @@ class ImageLoaderController(auth: Authentication, ).getOrElse(identifiers), uploadTime = DateTimeUtils.fromValueOrNow(maybeStatus.map(_.uploadTime).orElse(uploadTime)), filename = maybeStatus.flatMap(_.fileName).orElse(filename).flatMap(_.trim.nonEmptyOpt), + instance, + isFeedUpload = isFeedUpload ) result <- uploader.storeFile(uploadRequest) } yield { @@ -484,7 +559,7 @@ class ImageLoaderController(auth: Authentication, private def resolveUploadAndUpdateStatus ( uploadResultFuture: Future[UploadStatusUri], digestedFileFuture: Future[DigestedFile], - )(implicit logMarker:LogMarker):Future[Either[Response,UploadStatusUri]] = { + )(implicit logMarker:LogMarker, instance: Instance):Future[Either[Response,UploadStatusUri]] = { // combine the import result and digest file together into a single future uploadResultFuture.transformWith { // note that we use transformWith instead of zip here as we are still interested in value of digestedFile even if the import fails maybeImportResult => @@ -523,7 +598,7 @@ class ImageLoaderController(auth: Authentication, private def updateUploadStatusTable( uploadAttempt: Future[UploadStatusUri], digestedFile: DigestedFile - )(implicit logMarker: LogMarker): Future[Unit] = { + )(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = { def reportFailure(error: Throwable): Unit = { val errorMessage = s"an error occurred while updating image upload status, error:$error" @@ -574,6 +649,7 @@ class ImageLoaderController(auth: Authentication, private case class RestoreFromReplicaForm(imageId: String) def restoreFromReplica: Action[AnyContent] = AuthenticatedAndAuthorised.async { implicit request => + implicit val instance: Instance = instanceOf(request) val imageId = Form( mapping( @@ -624,7 +700,8 @@ class ImageLoaderController(auth: Authentication, metadata.uploadTime, metadata.uploadedBy, metadata.identifiers, - UploadInfo(metadata.uploadFileName) + UploadInfo(metadata.uploadFileName, metadata.isFeedUpload), + instance, ), gridClient, auth.getOnBehalfOfPrincipal(request.user) @@ -636,7 +713,7 @@ class ImageLoaderController(auth: Authentication, future.map { _ => logger.info(logMarker, s"Restored image $imageId from replica bucket $replicaBucket (key: $s3Key)") - Redirect(s"${config.kahunaUri}/images/$imageId") + Redirect(s"${config.kahunaUri(instance)}/images/$imageId") } case _ => Future.successful(NotFound("Image not found in replica bucket")) diff --git a/image-loader/app/controllers/UploadStatusController.scala b/image-loader/app/controllers/UploadStatusController.scala index 4bde0c8b3be..c449f8d2ffb 100644 --- a/image-loader/app/controllers/UploadStatusController.scala +++ b/image-loader/app/controllers/UploadStatusController.scala @@ -3,8 +3,10 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.lib.config.InstanceForRequest import lib._ import model.{StatusType, UploadStatus} +import com.gu.mediaservice.model.Instance import org.scanamo.{ConditionNotMet, ScanamoError} import play.api.mvc._ @@ -18,13 +20,14 @@ class UploadStatusController(auth: Authentication, authorisation: Authorisation ) (implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers { + extends BaseController with ArgoHelpers with InstanceForRequest { - def getUploadStatus(imageId: String) = auth.async { + def getUploadStatus(imageId: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) store.getStatus(imageId) .map { case Some(Right(record)) => respond(UploadStatus(record.status, record.errorMessage), - uri = Some(URI.create(s"${config.apiUri}/images/${imageId}"))) + uri = Some(URI.create(s"${config.apiUri(instance)}/images/$imageId"))) case Some(Left(error)) => respondError(BadRequest, "cannot-get", s"Cannot get upload status ${error}") case None => respondNotFound(s"No upload status found for image id: ${imageId}") } @@ -32,6 +35,7 @@ class UploadStatusController(auth: Authentication, } def updateUploadStatus(imageId: String) = (auth andThen authorisation.CommonActionFilters.authorisedForUpload).async(parse.json[UploadStatus]) { request => + implicit val instance: Instance = instanceOf(request) request.body match { case UploadStatus(StatusType.Failed, None) => Future.successful(respondError( @@ -55,6 +59,7 @@ class UploadStatusController(auth: Authentication, def getUploadsBy(user:String): Action[AnyContent] = getUploads(Some(user)) private def getUploads(maybeUser: Option[String]): Action[AnyContent] = auth.async { req => + implicit val instance: Instance = instanceOf(req) store.queryByUser(maybeUser.getOrElse(req.user.accessor.identity)) .map(list => respond(list)) } diff --git a/image-loader/app/lib/ImageLoaderConfig.scala b/image-loader/app/lib/ImageLoaderConfig.scala index 9417585a471..37d43aa0781 100644 --- a/image-loader/app/lib/ImageLoaderConfig.scala +++ b/image-loader/app/lib/ImageLoaderConfig.scala @@ -24,9 +24,9 @@ class ImageLoaderConfig(resources: GridConfigResources) extends CommonConfig(res val thumbWidth: Int = 256 val thumbQuality: Double = 85d // out of 100 - val rootUri: String = services.loaderBaseUri - val apiUri: String = services.apiBaseUri - val kahunaUri: String = services.kahunaBaseUri + val rootUri: Instance => String = services.loaderBaseUri + val apiUri: Instance => String = services.apiBaseUri + val kahunaUri: Instance => String = services.kahunaBaseUri val transcodedMimeTypes: List[MimeType] = getStringSet("transcoded.mime.types").toList.map(MimeType(_)) val supportedMimeTypes: List[MimeType] = List(Jpeg, Png) ::: transcodedMimeTypes @@ -36,6 +36,7 @@ class ImageLoaderConfig(resources: GridConfigResources) extends CommonConfig(res val shouldEmbed: Boolean = boolean("s3.vectors.shouldEmbed") val maybeImageEmbedderQueueUrl: Option[String] = stringOpt("sqs.image.embedder.queue.url") + /** * Load in the chain of image processors from config. This can be a list of * companion objects, class names, both with and without config. diff --git a/image-loader/app/lib/ImageLoaderStore.scala b/image-loader/app/lib/ImageLoaderStore.scala index 3758c583ff9..455a8337164 100644 --- a/image-loader/app/lib/ImageLoaderStore.scala +++ b/image-loader/app/lib/ImageLoaderStore.scala @@ -1,29 +1,32 @@ package lib.storage -import software.amazon.awssdk.services.s3.model.{CopyObjectRequest, DeleteObjectRequest, GetObjectRequest, GetObjectResponse, PutObjectRequest, S3Exception} - -import java.time.Duration -import scala.jdk.CollectionConverters.MapHasAsJava -import software.amazon.awssdk.core.ResponseInputStream -import software.amazon.awssdk.services.s3.presigner.S3Presigner -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import lib.ImageLoaderConfig import com.gu.mediaservice.lib -import com.gu.mediaservice.lib.logging.LogMarker +import com.gu.mediaservice.lib.aws +import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.model.Instance +import software.amazon.awssdk.core.ResponseInputStream +import software.amazon.awssdk.services.s3.model._ +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import java.io.File +import java.time.Duration +import scala.concurrent.Future +import scala.jdk.CollectionConverters.MapHasAsJava class S3FileDoesNotExistException extends Exception() -class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperations(config.imageBucket, config.thumbnailBucket, config) { +class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperations(config.imageBucket, config.thumbnailBucket, config) with GridLogging { private def handleNotFound[T](key: String)(doWork: => T)(loggingIfNotFound: => Unit): T = { try { doWork } catch { - case e: S3Exception if e.statusCode() == 404 || e.statusCode() == 403 => + case e: S3Exception if e.statusCode() == 404 || e.statusCode() == 403 => { + logger.warn(s"AmazonS3Exception ${e.statusCode()} for key '$key'") loggingIfNotFound throw new S3FileDoesNotExistException + } case other: Throwable => throw other } } @@ -35,19 +38,19 @@ class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperati logger.error(logMarker, s"Attempted to read $key from ingest bucket, but it does not exist.") } - def queueS3Object(uploader: String, filename: String, s3Meta: Map[String, String], file: File)(implicit logMarker: LogMarker) = { + def queueS3Object(uploader: String, filename: String, s3Meta: Map[String, String], file: File)(implicit logMarker: LogMarker, instance: Instance): Future[aws.S3Object] = { store( config.maybeIngestBucket.get, - s"$uploader/$filename", + s"${instance.id}/$uploader/$filename", file, mimeType = None, // we don't care as this is just the queue bucket meta = s3Meta, ) } - def generatePreSignedUploadUrl(filename: String, duration: Duration, uploadedBy: String, mediaId: String): String = { + def generatePreSignedUploadUrl(filename: String, duration: Duration, uploadedBy: String, mediaId: String)(implicit instance: Instance): String = { val putObjectRequest = PutObjectRequest.builder() - .bucket(config.maybeBucketForUIUploads.get).key(s"$uploadedBy/$filename").metadata(Map( + .bucket(config.maybeBucketForUIUploads.get).key(s"${instance.id}/$uploadedBy/$filename").metadata(Map( "media-id" -> mediaId).asJava) .build() val putObjectPresignRequest = @@ -63,9 +66,9 @@ class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperati def moveObjectToFailedBucket(key: String)(implicit logMarker: LogMarker) = handleNotFound(key){ client.copyObject( CopyObjectRequest.builder() - .sourceBucket(config.maybeIngestBucket.get) + .sourceBucket(config.maybeIngestBucket.get) // TODO Naked get - make optional .sourceKey(key) - .destinationBucket(config.maybeFailBucket.get) + .destinationBucket(config.maybeFailBucket.get) // TODO Naked get - make optional .destinationKey(key) .build() ) diff --git a/image-loader/app/lib/UploadStatusTable.scala b/image-loader/app/lib/UploadStatusTable.scala index d193b0a3e83..52a44d3401c 100644 --- a/image-loader/app/lib/UploadStatusTable.scala +++ b/image-loader/app/lib/UploadStatusTable.scala @@ -1,51 +1,56 @@ package lib +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance +import model.StatusType.{Prepared, Queued} +import model.{StatusType, UploadStatus, UploadStatusRecord} import org.scanamo._ -import org.scanamo.syntax._ import org.scanamo.generic.auto._ -import model.StatusType.{Prepared, Queued} -import model.{UploadStatus, UploadStatusRecord} -import software.amazon.awssdk.services.dynamodb.{DynamoDbAsyncClient, DynamoDbAsyncClientBuilder} +import org.scanamo.syntax._ +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -class UploadStatusTable(config: ImageLoaderConfig) { +class UploadStatusTable(config: ImageLoaderConfig) extends GridLogging { + + implicit val statusTypeFormat: Typeclass[StatusType] = + DynamoFormat.coercedXmap[StatusType, String, IllegalArgumentException](StatusType.apply, _.name) val client = config.withAWSCredentials(DynamoDbAsyncClient.builder()).build() val scanamo = ScanamoAsync(client) private val uploadStatusTable = Table[UploadStatusRecord](config.uploadStatusTable) - def getStatus(imageId: String) = { - scanamo.exec(uploadStatusTable.get("id" === imageId)) + def getStatus(imageId: String)(implicit instance: Instance) = { + scanamo.exec(uploadStatusTable.get("instance" === instance.id and "id" === imageId)) } def setStatus(uploadStatus: UploadStatusRecord) = { scanamo.exec(uploadStatusTable.put(uploadStatus)) } - def updateStatus(imageId: String, updateRequest: UploadStatus) = { - val updateExpression = updateRequest.errorMessage match { - case Some(error) => set("status", updateRequest.status) and set("errorMessage", error) - case None => set("status", updateRequest.status) + def updateStatus(imageId: String, updateStatus: UploadStatus)(implicit instance: Instance) = { + val updateExpression = updateStatus.errorMessage match { + case Some(error) => set("status", updateStatus.status) and set("errorMessage", error) + case None => set("status", updateStatus.status) } val uploadStatusTableWithCondition = - if(updateRequest.status == Queued) // can only transition to Queued status from Prepared status - uploadStatusTable.when(attributeExists("id") and ("status" === Prepared.toString)) + if(updateStatus.status == Queued) // can only transition to Queued status from Prepared status + uploadStatusTable.when(attributeExists("id") and attributeExists("instance") and "status" === Prepared.name) else - uploadStatusTable.when(attributeExists("id")) + uploadStatusTable.when(attributeExists("id") and attributeExists("instance")) scanamo.exec( uploadStatusTableWithCondition .update( - key = "id" === imageId, + "id" === imageId and "instance" === instance.id, update = updateExpression ) ) } - def queryByUser(user: String): Future[List[UploadStatusRecord]] = { - scanamo.exec(uploadStatusTable.scan()).map { + def queryByUser(user: String)(implicit instance: Instance): Future[List[UploadStatusRecord]] = { + scanamo.exec(uploadStatusTable.query("instance" === instance.id)).map { case Nil => List.empty[UploadStatusRecord] case recordsAndErrors => { recordsAndErrors diff --git a/image-loader/app/model/Projector.scala b/image-loader/app/model/Projector.scala index 41dcf25fa00..cc469d886bd 100644 --- a/image-loader/app/model/Projector.scala +++ b/image-loader/app/model/Projector.scala @@ -1,30 +1,29 @@ package model -import software.amazon.awssdk.core.ResponseInputStream -import software.amazon.awssdk.services.s3.model.GetObjectResponse - -import java.io.{File, FileOutputStream} -import com.gu.mediaservice.{GridClient, ImageDataMerger} -import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId} -import com.gu.mediaservice.lib.{ImageIngestOperations, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.{Embedder, S3, S3Object} +import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{Embedder, S3} import com.gu.mediaservice.lib.cleanup.ImageProcessor +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.lib.net.URI -import com.gu.mediaservice.model.{Image, MimeType, UploadInfo} +import com.gu.mediaservice.lib._ +import com.gu.mediaservice.model.{Image, Instance, MimeType, UploadInfo} +import com.gu.mediaservice.{GridClient, ImageDataMerger} import lib.imaging.{MimeTypeDetection, NoSuchImageExistsInS3} import lib.{DigestedFile, ImageLoaderConfig} import model.upload.UploadRequest import org.apache.commons.io.IOUtils import org.joda.time.{DateTime, DateTimeZone} -import play.api.libs.ws.WSRequest +import _root_.play.api.libs.ws.WSRequest +import software.amazon.awssdk.core.ResponseInputStream +import software.amazon.awssdk.services.s3.model.GetObjectResponse -import java.time.Instant -import scala.jdk.CollectionConverters._ +import java.io.{File, FileOutputStream} import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} +import scala.jdk.CollectionConverters._ object Projector { @@ -38,11 +37,12 @@ case class S3FileExtractedMetadata( uploadedBy: String, uploadTime: DateTime, uploadFileName: Option[String], - identifiers: Map[String, String] + identifiers: Map[String, String], + isFeedUpload: Option[Boolean], ) object S3FileExtractedMetadata { - + def apply(lastModified: DateTime, userMetadata: Map[String, String]): S3FileExtractedMetadata = { val fileUserMetadata = userMetadata.map { case (key, value) => // Fix up the contents of the metadata. @@ -63,6 +63,7 @@ object S3FileExtractedMetadata { }.map{ case (key, value) => key.stripPrefix(ImageStorageProps.identifierMetadataKeyPrefix) -> value } + val isFeedUpload = fileUserMetadata.get(ImageStorageProps.isFeedUploadMetadataKey).map(_.toBoolean) val uploadFileName = fileUserMetadata.get(ImageStorageProps.filenameMetadataKey) @@ -71,6 +72,7 @@ object S3FileExtractedMetadata { uploadTime = uploadTime, uploadFileName = uploadFileName, identifiers = identifiers, + isFeedUpload = isFeedUpload, ) } } @@ -80,12 +82,12 @@ class Projector(config: ImageUploadOpsCfg, imageOps: ImageOperations, processor: ImageProcessor, auth: Authentication, - maybeEmbedder: Option[Embedder]) extends GridLogging { + maybeEmbedder: Option[Embedder]) extends GridLogging with InstanceForRequest { private val imageUploadProjectionOps = new ImageUploadProjectionOps(config, imageOps, processor, s3, maybeEmbedder) def projectS3ImageById(imageId: String, tempFile: File, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = { + (implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[Image]] = { Future { import ImageIngestOperations.fileKeyFromId val s3Key = fileKeyFromId(imageId) @@ -125,11 +127,11 @@ class Projector(config: ImageUploadOpsCfg, extractedS3Meta: S3FileExtractedMetadata, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { + (implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Image] = { val DigestedFile(tempFile_, id_) = srcFileDigest val identifiers_ = extractedS3Meta.identifiers - val uploadInfo_ = UploadInfo(filename = extractedS3Meta.uploadFileName) + val uploadInfo_ = UploadInfo(filename = extractedS3Meta.uploadFileName, isFeedUpload = extractedS3Meta.isFeedUpload) MimeTypeDetection.guessMimeType(tempFile_) match { case util.Left(unsupported) => Future.failed(unsupported) @@ -141,7 +143,8 @@ class Projector(config: ImageUploadOpsCfg, uploadTime = extractedS3Meta.uploadTime, uploadedBy = extractedS3Meta.uploadedBy, identifiers = identifiers_, - uploadInfo = uploadInfo_ + uploadInfo = uploadInfo_, + instance = instance, // TODO careful with this one! ) imageUploadProjectionOps.projectImageFromUploadRequest(uploadRequest) flatMap ( @@ -158,12 +161,12 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, maybeEmbedder: Option[Embedder], ) extends GridLogging { - import Uploader.{fromUploadRequestShared, toMetaMap} + import Uploader.fromUploadRequestShared def projectImageFromUploadRequest(uploadRequest: UploadRequest) (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { - val dependenciesWithProjectionsOnly = ImageUploadOpsDependencies( + val dependenciesWithProjectionsOnly: ImageUploadOpsDependencies = ImageUploadOpsDependencies( config, imageOps, projectOriginalFileAsS3Model, @@ -186,17 +189,15 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, Future.successful(storableOptimisedImage.toProjectedS3Object(config.originalFileBucket)) private def fetchThumbFile( - imageId: String, outFile: File - )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { - val key = fileKeyFromId(imageId) - + imageId: String, outFile: File, instance: Instance)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { + val key = fileKeyFromId(imageId)(instance) fetchFile(config.thumbBucket, key, outFile) } private def fetchOptimisedFile( - imageId: String, outFile: File + imageId: String, outFile: File, instance: Instance )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { - val key = optimisedPngKeyFromId(imageId) + val key = optimisedPngKeyFromId(imageId)(instance) fetchFile(config.originalFileBucket, key, outFile) } diff --git a/image-loader/app/model/QuarantineUploader.scala b/image-loader/app/model/QuarantineUploader.scala index af75e44a73c..b18079981ee 100644 --- a/image-loader/app/model/QuarantineUploader.scala +++ b/image-loader/app/model/QuarantineUploader.scala @@ -18,6 +18,8 @@ import play.api.Logger import play.api.libs.json.{JsObject, Json} import com.gu.mediaservice.lib.formatting._ import model.upload.UploadRequest +import play.api.mvc.{AnyContent, Request} + import java.net.URLEncoder import java.nio.charset.StandardCharsets import scala.concurrent.{ExecutionContext, Future} @@ -28,6 +30,7 @@ class QuarantineUploader(val store: QuarantineStore, private def storeQuarantineFile(uploadRequest: UploadRequest) (implicit logMarker: LogMarker) = { + implicit val instance: Instance = uploadRequest.instance val meta = Uploader.toMetaMap(uploadRequest) store.storeQuarantineImage( uploadRequest.imageId, @@ -37,7 +40,7 @@ class QuarantineUploader(val store: QuarantineStore, ) } - def quarantineFile(uploadRequest: UploadRequest)( + def quarantineFile(uploadRequest: UploadRequest)(instance: Instance) ( implicit ec: ExecutionContext, logMarker: LogMarker): Future[JsObject] = { @@ -45,7 +48,7 @@ class QuarantineUploader(val store: QuarantineStore, for { _ <- storeQuarantineFile(uploadRequest) - uri = s"${config.rootUri}/uploadStatus/${uploadRequest.imageId}" + uri = s"${config.rootUri(instance)}/uploadStatus/${uploadRequest.imageId}" } yield { Json.obj("uri" -> uri) } diff --git a/image-loader/app/model/S3IngestObject.scala b/image-loader/app/model/S3IngestObject.scala index c6179d83635..65127032207 100644 --- a/image-loader/app/model/S3IngestObject.scala +++ b/image-loader/app/model/S3IngestObject.scala @@ -15,7 +15,8 @@ case class S3IngestObject ( uploadTime: java.util.Date, contentLength: Long, getInputStream: () => java.io.InputStream, - identifiers: Map[String, String] = Map.empty + identifiers: Map[String, String] = Map.empty, + isFeedUpload: Boolean, ) object S3IngestObject { @@ -25,19 +26,34 @@ object S3IngestObject { val s3Object = store.getS3Object(key) val metadata = s3Object.response().metadata() + + val mediaIdFromUiUpload = metadata.asScala.get("media-id") + val isFeedUpload = mediaIdFromUiUpload.isEmpty // TODO Not concise + S3IngestObject( key, - uploadedBy = keyParts.head, + uploadedBy = uploadedFromPath(keyParts), filename = keyParts.last, - maybeMediaIdFromUiUpload = metadata.asScala.toMap.get("media-id"), // set by the client in upload in manager.js + maybeMediaIdFromUiUpload = mediaIdFromUiUpload, // set by the client in upload in manager.js uploadTime = new Date(s3Object.response().lastModified().toEpochMilli), contentLength = s3Object.response().contentLength(), getInputStream = () => s3Object, identifiers = metadata.asScala.collect{ case (key, value) if key.startsWith(ImageStorageProps.identifierMetadataKeyPrefix) => key.stripPrefix(ImageStorageProps.identifierMetadataKeyPrefix) -> value - }.toMap + }.toMap, + isFeedUpload = isFeedUpload ) } + + def uploadedFromPath(keyParts: Array[String]): String = { + val indexOfFeedProviderName = if (keyParts.contains("feeds")) { + keyParts.indexOf("feeds") + 1 + } else { + 0 + } + + keyParts(Seq(indexOfFeedProviderName, 0).max) + } } diff --git a/image-loader/app/model/UploadStatus.scala b/image-loader/app/model/UploadStatus.scala index 044acacf713..af28678e23f 100644 --- a/image-loader/app/model/UploadStatus.scala +++ b/image-loader/app/model/UploadStatus.scala @@ -10,7 +10,8 @@ case class UploadStatusRecord( identifiers: Option[String], status: StatusType, errorMessage: Option[String], - expires: Long + expires: Long, + instance: String ) object UploadStatusRecord { diff --git a/image-loader/app/model/Uploader.scala b/image-loader/app/model/Uploader.scala index 6e6c7acc26b..6b3f722d715 100644 --- a/image-loader/app/model/Uploader.scala +++ b/image-loader/app/model/Uploader.scala @@ -1,14 +1,12 @@ package model -import com.gu.mediaservice.{GridClient, ImageDataMerger} +import _root_.play.api.libs.json.Json +import _root_.play.api.libs.ws.WSRequest import com.gu.mediaservice.lib.Files.createTempFile import com.gu.mediaservice.lib.ImageIngestOperations.fileKeyFromId - -import java.io.File -import java.nio.file.{Files, Path} +import com.gu.mediaservice.lib._ import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.{BrowserViewableImage, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} import com.gu.mediaservice.lib.aws.{Embedder, EmbedderMessage, S3Object, UpdateMessage} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.formatting._ @@ -19,19 +17,18 @@ import com.gu.mediaservice.lib.metadata.{FileMetadataHelper, ImageMetadataConver import com.gu.mediaservice.lib.net.URI import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects -import lib.{DigestedFile, ImageLoaderConfig, Notifications} +import com.gu.mediaservice.{GridClient, ImageDataMerger} import lib.imaging.{FileMetadataReader, MimeTypeDetection} import lib.storage.ImageLoaderStore +import lib.{DigestedFile, ImageLoaderConfig, Notifications} import model.Uploader.{fromUploadRequestShared, toImageUploadOpsCfg} import model.upload.{OptimiseOps, OptimiseWithPngQuant, UploadRequest} import org.joda.time.DateTime -import play.api.libs.json.Json -import play.api.libs.ws.WSRequest import software.amazon.awssdk.services.s3.model.CopyObjectRequest -import scala.collection.compat._ +import java.io.File +import java.nio.file.Files import scala.concurrent.{ExecutionContext, Future} -import scala.jdk.CollectionConverters.MapHasAsScala case class ImageUpload(uploadRequest: UploadRequest, image: Image) @@ -80,8 +77,8 @@ case class ImageUploadOpsDependencies( storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object], storeOrProjectThumbFile: StorableThumbImage => Future[S3Object], storeOrProjectOptimisedImage: StorableOptimisedImage => Future[S3Object], - tryFetchThumbFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), - tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), + tryFetchThumbFile: (String, File, Instance) => Future[Option[(File, MimeType)]] = (_, _, _) => Future.successful(None), + tryFetchOptimisedFile: (String, File, Instance) => Future[Option[(File, MimeType)]] = (_, _, _) => Future.successful(None), ) @@ -141,6 +138,7 @@ object Uploader extends GridLogging { case Some(a) => a case None => throw new Exception("File of unknown and undetectable mime type") } + logger.info("Original Mime type: " + originalMimeType) val tempDirForRequest: File = Files.createTempDirectory(deps.config.tempDir.toPath, "upload").toFile @@ -153,7 +151,8 @@ object Uploader extends GridLogging { uploadRequest.tempFile, originalMimeType, uploadRequest.uploadTime, - toMetaMap(uploadRequest) + toMetaMap(uploadRequest), + uploadRequest.instance ) val sourceStoreFuture = storeOrProjectOriginalFile(storableOriginalImage) val eventualBrowserViewableImage = createBrowserViewableFileFuture(uploadRequest, tempDirForRequest, deps) @@ -165,10 +164,10 @@ object Uploader extends GridLogging { optimisedFileMetadata <- FileMetadataReader.fromIPTCHeadersWithColorInfo(browserViewableImage) sourceDimensions <- sourceDimensionsFuture sourceOrientationMetadata <- sourceOrientationMetadataFuture - thumbViewableImage <- createThumbFuture(optimisedFileMetadata, colourModelFuture, browserViewableImage, deps, tempDirForRequest, orientationMetadata = sourceOrientationMetadata) + thumbViewableImage <- createThumbFuture(optimisedFileMetadata, colourModelFuture, browserViewableImage, deps, tempDirForRequest, uploadRequest.instance, orientationMetadata = sourceOrientationMetadata) s3Thumb <- storeOrProjectThumbFile(thumbViewableImage) maybeStorableOptimisedImage <- getStorableOptimisedImage( - tempDirForRequest, optimiseOps, browserViewableImage, optimisedFileMetadata, deps.tryFetchOptimisedFile) + tempDirForRequest, optimiseOps, browserViewableImage, optimisedFileMetadata, deps.tryFetchOptimisedFile, uploadRequest.instance) s3PngOption <- maybeStorableOptimisedImage match { case Some(storableOptimisedImage) => storeOrProjectOptimisedFile(storableOptimisedImage).map(a=>Some(a)) case None => Future.successful(None) @@ -212,12 +211,13 @@ object Uploader extends GridLogging { optimiseOps: OptimiseOps, browserViewableImage: BrowserViewableImage, optimisedFileMetadata: FileMetadata, - tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] + tryFetchOptimisedFile: (String, File, Instance) => Future[Option[(File, MimeType)]], + instance: Instance )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[StorableOptimisedImage]] = { if (optimiseOps.shouldOptimise(Some(browserViewableImage.mimeType), optimisedFileMetadata)) { for { tempFile <- createTempFile("optimisedpng-", optimisedMimeType.fileExtension, tempDir) - maybeDownloadedOptimisedFile <- tryFetchOptimisedFile(browserViewableImage.id, tempFile) + maybeDownloadedOptimisedFile <- tryFetchOptimisedFile(browserViewableImage.id, tempFile, instance) (optimisedFile, optimisedMimeType) <- { maybeDownloadedOptimisedFile match { case Some(optData) => Future.successful(optData) @@ -239,10 +239,11 @@ object Uploader extends GridLogging { def toMetaMap(uploadRequest: UploadRequest): Map[String, String] = { val baseMeta = Map( ImageStorageProps.uploadedByMetadataKey -> uploadRequest.uploadedBy, - ImageStorageProps.uploadTimeMetadataKey -> printDateTime(uploadRequest.uploadTime) + ImageStorageProps.uploadTimeMetadataKey -> printDateTime(uploadRequest.uploadTime), ) ++ uploadRequest.identifiersMeta ++ - uploadRequest.uploadInfo.filename.map(ImageStorageProps.filenameMetadataKey -> _) + uploadRequest.uploadInfo.filename.map(ImageStorageProps.filenameMetadataKey -> _) ++ + uploadRequest.uploadInfo.isFeedUpload.map(ImageStorageProps.isFeedUploadMetadataKey -> _.toString) baseMeta.view.mapValues(URI.encode).toMap } @@ -259,7 +260,8 @@ object Uploader extends GridLogging { browserViewableImage: BrowserViewableImage, deps: ImageUploadOpsDependencies, tempDir: File, - orientationMetadata: Option[OrientationMetadata], + instance: Instance, + orientationMetadata: Option[OrientationMetadata] )(implicit ec: ExecutionContext, logMarker: LogMarker) = { import deps._ @@ -281,7 +283,7 @@ object Uploader extends GridLogging { for { tempFile <- createTempFile(s"thumb-", thumbMimeType.fileExtension, tempDir) - maybeThumbFile <- deps.tryFetchThumbFile(browserViewableImage.id, tempFile) + maybeThumbFile <- deps.tryFetchThumbFile(browserViewableImage.id, tempFile, instance) (thumb, thumbMimeType) <- { maybeThumbFile match { case Some(thumbData) => Future.successful(thumbData) @@ -307,14 +309,16 @@ object Uploader extends GridLogging { uploadRequest.imageId, file = file, mimeType = mimeType, - isTransformedFromSource = true + isTransformedFromSource = true, + instance = uploadRequest.instance ) case Some(mimeType) => Future.successful( BrowserViewableImage( uploadRequest.imageId, file = uploadRequest.tempFile, - mimeType = mimeType) + mimeType = mimeType, + instance = uploadRequest.instance) ) case None => Future.failed(new Exception("This file is not an image with an identifiable mime type")) } @@ -350,7 +354,7 @@ class Uploader( isReplacement: Boolean )( mediaIdToAddUsageTo: String - ) = { + )(implicit instance: Instance) = { gridClient.postUsage( usageType = "child", data = Json.obj( @@ -367,7 +371,7 @@ class Uploader( } private def fromUploadRequest(uploadRequest: UploadRequest) - (implicit logMarker: LogMarker): Future[ImageUpload] = { + (implicit logMarker: LogMarker, instance: Instance): Future[ImageUpload] = { val sideEffectDependencies = ImageUploadOpsDependencies(toImageUploadOpsCfg(config), imageOps, storeSource, storeThumbnail, storeOptimisedImage) Stopwatch.async("finalImage") { @@ -406,7 +410,9 @@ class Uploader( uploadedBy: String, identifiers: Map[String, String], uploadTime: DateTime, - filename: Option[String]) + filename: Option[String], + instance: Instance, + isFeedUpload: Boolean) (implicit ec:ExecutionContext, logMarker: LogMarker): Future[UploadRequest] = Future { val DigestedFile(tempFile, id) = digestedFile @@ -429,20 +435,21 @@ class Uploader( uploadTime = uploadTime, uploadedBy = uploadedBy, identifiers = identifiersMap, - uploadInfo = UploadInfo(filename) + uploadInfo = UploadInfo(filename, Some(isFeedUpload)), + instance = instance ) } } def storeFile(uploadRequest: UploadRequest) (implicit ec:ExecutionContext, - logMarker: LogMarker): Future[UploadStatusUri] = { + logMarker: LogMarker, instance: Instance): Future[UploadStatusUri] = { logger.info(logMarker, "Storing file") for { imageUpload <- fromUploadRequest(uploadRequest) - updateMessage = UpdateMessage(subject = Image, image = Some(imageUpload.image)) + updateMessage = UpdateMessage(subject = Image, image = Some(imageUpload.image), instance = uploadRequest.instance) _ <- Future { notifications.publish(updateMessage) } // Send the optimised PNG to the embedder if there is one (e.g. for TIFFs), // otherwise send the original image. @@ -464,6 +471,7 @@ class Uploader( mimeTypeForEmbedder, s3BucketForEmbedder, s3KeyForEmbedder, + instance.id )) // TODO: centralise where all these URLs are constructed } yield { @@ -487,7 +495,7 @@ class Uploader( } } - UploadStatusUri(s"${config.rootUri}/uploadStatus/${uploadRequest.imageId}") + UploadStatusUri(s"${config.rootUri(instance)}/uploadStatus/${uploadRequest.imageId}") } } @@ -496,13 +504,14 @@ class Uploader( gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) (implicit ec: ExecutionContext, - logMarker: LogMarker): Future[Unit] = for { + logMarker: LogMarker, + instance: Instance): Future[Unit] = for { imageUpload <- fromUploadRequest(uploadRequest) imageWithoutUserEdits = imageUpload.image imageWithUserEditsApplied <- ImageDataMerger.aggregate(imageWithoutUserEdits, gridClient, onBehalfOfFn) _ <- Future { notifications.publish( - UpdateMessage(subject = Image, image = Some(imageWithUserEditsApplied)) + UpdateMessage(subject = Image, image = Some(imageWithUserEditsApplied), instance = uploadRequest.instance) ) } } yield () diff --git a/image-loader/app/model/upload/UploadRequest.scala b/image-loader/app/model/upload/UploadRequest.scala index cf5fb3848c6..57b83e72c0c 100644 --- a/image-loader/app/model/upload/UploadRequest.scala +++ b/image-loader/app/model/upload/UploadRequest.scala @@ -4,7 +4,7 @@ import com.gu.mediaservice.lib.ImageStorageProps import java.io.File import java.util.UUID -import com.gu.mediaservice.model.{MimeType, UploadInfo} +import com.gu.mediaservice.model.{Instance, MimeType, UploadInfo} import net.logstash.logback.marker.{LogstashMarker, Markers} import org.joda.time.format.ISODateTimeFormat import org.joda.time.{DateTime, DateTimeZone} @@ -19,6 +19,7 @@ case class UploadRequest( uploadedBy: String, identifiers: Map[String, String], uploadInfo: UploadInfo, + instance: Instance ) { val identifiersMeta: Map[String, String] = identifiers.map { case (k, v) => diff --git a/image-loader/test/scala/model/ImageUploadTest.scala b/image-loader/test/scala/model/ImageUploadTest.scala index 2430a911a2f..9711519a0cf 100644 --- a/image-loader/test/scala/model/ImageUploadTest.scala +++ b/image-loader/test/scala/model/ImageUploadTest.scala @@ -1,15 +1,12 @@ package model -import java.io.File -import java.net.URI -import java.util.UUID import com.drew.imaging.ImageProcessingException -import com.gu.mediaservice.lib.{StorableImage, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.{EmbedderMessage, S3Metadata, S3Object, S3ObjectMetadata, S3Ops} +import com.gu.mediaservice.lib.aws.{S3Metadata, S3Object, S3ObjectMetadata} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.{FileMetadata, Jpeg, MimeType, Png, Tiff, UploadInfo} +import com.gu.mediaservice.lib.{StorableImage, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} +import com.gu.mediaservice.model._ import lib.imaging.MimeTypeDetection import model.upload.{OptimiseWithPngQuant, UploadRequest} import org.joda.time.DateTime @@ -17,10 +14,11 @@ import org.scalatest.Assertion import org.scalatest.funsuite.AsyncFunSuite import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar -import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import test.lib.ResourceHelpers -import java.nio.file.Path +import java.io.File +import java.net.URI +import java.util.UUID import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -71,7 +69,7 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { ) val tempFile = ResourceHelpers.fileAt(fileName) - val ul = UploadInfo(None) + val ul = UploadInfo(None, None) val uploadRequest = UploadRequest( imageId = randomId, @@ -80,7 +78,8 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { uploadTime = DateTime.now(), uploadedBy = "uploadedBy", identifiers = Map(), - uploadInfo = ul + uploadInfo = ul, + Instance("an-instance"), ) val futureImage = Uploader.uploadAndStoreImage( diff --git a/image-loader/test/scala/model/ProjectorTest.scala b/image-loader/test/scala/model/ProjectorTest.scala index 4de3767ef3f..3c9e27e87ea 100644 --- a/image-loader/test/scala/model/ProjectorTest.scala +++ b/image-loader/test/scala/model/ProjectorTest.scala @@ -9,7 +9,7 @@ import com.gu.mediaservice.lib.aws.S3 import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} -import com.gu.mediaservice.model._ +import com.gu.mediaservice.model.{Instance, _} import com.gu.mediaservice.model.leases.LeasesByMedia import lib.DigestedFile import org.joda.time.{DateTime, DateTimeZone} @@ -23,6 +23,7 @@ import org.scalatestplus.mockito.MockitoSugar import play.api.libs.json.{JsArray, JsString} import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse +import play.api.mvc.RequestHeader import test.lib.ResourceHelpers import java.nio.file.Path @@ -48,6 +49,8 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc private val auth = mock[Authentication] private val projector = new Projector(config, s3, imageOperations, ImageProcessor.identity, auth, maybeEmbedder) + private implicit val instance: Instance = Instance("an-instance") + // FIXME temporary ignored as test is not executable in CI/CD machine // because graphic lib files like srgb.icc, cmyk.icc are in root directory instead of resources // this test is passing when running on local machine @@ -135,7 +138,7 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc softDeletedMetadata = None, lastModified = Some(new DateTime("2020-01-24T17:36:08.456Z").withZone(DateTimeZone.UTC)), identifiers = Map(), - uploadInfo = UploadInfo(Some("getty.jpg")), + uploadInfo = UploadInfo(Some("getty.jpg"), Some(true)), source = Asset(new URI("http://img-bucket.s3.amazonaws.com/i/d/1/2/3/" + id), Some(12666), Some(Jpeg), @@ -200,9 +203,11 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc uploadTime = uploadTime, uploadFileName = uploadFileName, identifiers = Map.empty, + isFeedUpload = Some(true), ) implicit val logMarker: LogMarker = MarkerMap() + implicit val instance: Instance = Instance("an-instance") val gridClient = mock[GridClient] when(gridClient.getUsages(id, identity)).thenReturn(Future.successful(Nil)) diff --git a/image-loader/test/scala/model/S3IngestObjectTest.scala b/image-loader/test/scala/model/S3IngestObjectTest.scala new file mode 100644 index 00000000000..ff0512ef379 --- /dev/null +++ b/image-loader/test/scala/model/S3IngestObjectTest.scala @@ -0,0 +1,14 @@ +package scala.model + +import model.S3IngestObject +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper + +class S3IngestObjectTest extends AnyFlatSpec { + + it should "infer uploader from folder after feeds" in { + val keyParts = "fingerpost/feeds/20250226/PA/205350.SOCCER-Kilmarnock--20514432_PICTURES_PRI13.jpg".split("/") + S3IngestObject.uploadedFromPath(keyParts) shouldBe "20250226" + } + +} diff --git a/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala b/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala new file mode 100644 index 00000000000..3a28c8b1ec9 --- /dev/null +++ b/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala @@ -0,0 +1,17 @@ +import com.gu.mediaservice.lib.config.InstanceForRequest +import lib.KahunaConfig +import play.api.Configuration +import play.api.mvc.{EssentialAction, EssentialFilter, RequestHeader} +import play.filters.headers._ + +class InstanceSpecificSecurityHeaderFilter(config: KahunaConfig, playConfig: Configuration) + extends EssentialFilter with InstanceForRequest { + + override def apply(next: EssentialAction): EssentialAction = (req: RequestHeader) => { + val instance = instanceOf(req) + val kahunaSecurityConfig = KahunaSecurityConfig.apply(config, playConfig, instance) + val instanceSpecificSecurityHeadersFilter = SecurityHeadersFilter(kahunaSecurityConfig: SecurityHeadersConfig).apply(next) + instanceSpecificSecurityHeadersFilter.apply(req) + } + +} diff --git a/kahuna/app/KahunaComponents.scala b/kahuna/app/KahunaComponents.scala index 9c179e485c5..619db511599 100644 --- a/kahuna/app/KahunaComponents.scala +++ b/kahuna/app/KahunaComponents.scala @@ -1,17 +1,27 @@ import com.gu.mediaservice.lib.net.URI -import com.gu.mediaservice.lib.play.GridComponents +import com.gu.mediaservice.lib.play.{ConnectionBrokenFilter, GridComponents, RequestLoggingFilter, RequestMetricFilter} +import com.gu.mediaservice.model.Instance import controllers.{AssetsComponents, KahunaController} import lib.KahunaConfig import play.api.ApplicationLoader.Context import play.api.Configuration +import play.api.mvc.EssentialFilter import play.filters.headers.SecurityHeadersConfig import router.Routes class KahunaComponents(context: Context) extends GridComponents(context, new KahunaConfig(_)) with AssetsComponents { - final override lazy val securityHeadersConfig: SecurityHeadersConfig = KahunaSecurityConfig(config, context.initialConfiguration) - final override val buildInfo = utils.buildinfo.BuildInfo + override def httpFilters: Seq[EssentialFilter] = Seq( + instanceSpecificCorsFilter, + // csrfFilter, TODO Ineffective as gateway is not setting correct hostname headers! + new InstanceSpecificSecurityHeaderFilter(config, context.initialConfiguration), + gzipFilter, + new RequestLoggingFilter(materializer), + new ConnectionBrokenFilter(materializer), + new RequestMetricFilter(config, materializer, actorSystem, applicationLifecycle) + ) + val controller = new KahunaController(auth, config, controllerComponents, authorisation) final override val router = new Routes(httpErrorHandler, controller, assets, management) @@ -19,31 +29,30 @@ class KahunaComponents(context: Context) extends GridComponents(context, new Kah } object KahunaSecurityConfig { - def apply(config: KahunaConfig, playConfig: Configuration): SecurityHeadersConfig = { + def apply(config: KahunaConfig, playConfig: Configuration, instance: Instance): SecurityHeadersConfig = { val base = SecurityHeadersConfig.fromConfiguration(playConfig) val services = List( - config.services.apiBaseUri, - config.services.loaderBaseUri, - config.services.cropperBaseUri, - config.services.metadataBaseUri, - config.services.imgopsBaseUri, - config.services.usageBaseUri, - config.services.collectionsBaseUri, - config.services.leasesBaseUri, - config.services.authBaseUri, + config.services.apiBaseUri(instance), + config.services.loaderBaseUri(instance), + config.services.cropperBaseUri(instance), + config.services.metadataBaseUri(instance), + config.services.imgopsBaseUri(instance), + config.services.usageBaseUri(instance), + config.services.collectionsBaseUri(instance), + config.services.leasesBaseUri(instance), + config.services.authBaseUri(instance), config.services.guardianWitnessBaseUri ) - val frameSources = s"frame-src ${config.services.authBaseUri} ${config.services.kahunaBaseUri} https://accounts.google.com https://www.youtube.com ${config.scriptsToLoad.map(_.host).mkString(" ")}" + val frameSources = s"frame-src https://accounts.google.com https://www.youtube.com ${config.scriptsToLoad.map(_.host).mkString(" ")}" val frameAncestors = s"frame-ancestors ${config.frameAncestors.mkString(" ")}" - val connectSources = s"connect-src 'self' ${(services :+ config.imageOrigin).mkString(" ")} ${config.connectSources.mkString(" ")}" + val connectSources = s"connect-src 'self' ${config.connectSources.mkString(" ")}" - val imageSources = s"img-src ${List( + val imageSources = s"img-src ${List( "data:", "blob:", - URI.ensureSecure(config.services.imgopsBaseUri).toString, - URI.ensureSecure(config.fullOrigin).toString, + URI.ensureSecure(config.services.imgopsBaseUri(instance)).toString, URI.ensureSecure(config.thumbOrigin).toString, URI.ensureSecure(config.cropOrigin).toString, URI.ensureSecure("app.getsentry.com").toString, diff --git a/kahuna/app/controllers/KahunaController.scala b/kahuna/app/controllers/KahunaController.scala index 816350e42e6..78b21840d2d 100644 --- a/kahuna/app/controllers/KahunaController.scala +++ b/kahuna/app/controllers/KahunaController.scala @@ -3,13 +3,14 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, BaseControllerWithLoginRedirects} -import lib.{ExampleSwitch, FeatureSwitches, KahunaConfig, UseCqlChips} +import lib.{ExampleSwitch, FeatureSwitches, KahunaConfig, KahunaClientServiceUrls, UseCqlChips} import play.api.mvc.ControllerComponents import play.api.libs.json._ import scala.concurrent.ExecutionContext import com.gu.mediaservice.lib.config.FieldAlias._ -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.config.{InstanceForRequest, Services} +import com.gu.mediaservice.model.Instance import play.api.mvc.Security.AuthenticatedRequest import play.twirl.api.Html @@ -20,13 +21,14 @@ class KahunaController( authorisation: Authorisation )( implicit val ec: ExecutionContext -) extends BaseControllerWithLoginRedirects with ArgoHelpers { +) extends BaseControllerWithLoginRedirects with ArgoHelpers with InstanceForRequest { override def auth: Authentication = authentication override def services: Services = config.services def index(ignored: String) = withOptionalLoginRedirect { request => + implicit val instance: Instance = instanceOf(request) val maybeUser: Option[Authentication.Principal] = request match { case authedRequest: AuthenticatedRequest[_, _] => authedRequest.user match { @@ -55,19 +57,27 @@ class KahunaController( val metadataTemplates: String = Json.toJson(config.metadataTemplates).toString() val announcements: String = Json.toJson(config.announcements).toString() val interimFilterOptions: String = Json.toJson(config.interimFilterOptions).toString() - val returnUri = config.rootUri + okPath + val returnUri = config.rootUri(instance) + okPath val costFilterLabel = config.costFilterLabel.getOrElse("Free to use only") val costFilterChargeable = config.costFilterChargeable.getOrElse(false) val maybeOrgOwnedValue = if(config.shouldDisplayOrgOwnedCountAndFilterCheckbox) - Html(s""""${config.staffPhotographerOrganisation}-owned"""") + Html(""""owned"""") else Html("undefined") val imageTypes = Json.toJson(config.imageTypes).toString() val agencyPicksIngredients = Json.toJson(config.agencyPicksIngredients).toString() + val rootUri = config.rootUri(instance) + + val kahunaClientServiceUrls = KahunaClientServiceUrls( + rootUri = rootUri, + mediaApiUri = config.mediaApiUri(instance), + authUri = config.authUri(instance) + ) + Ok(views.html.main( - s"${config.authUri}/login?redirectUri=$returnUri", + s"${config.authUri(instance)}/login?redirectUri=$returnUri", fieldAliases, scriptsToLoad, domainMetadataSpecs, @@ -81,12 +91,13 @@ class KahunaController( config, featureSwitchesJson, imageTypes, - agencyPicksIngredients + agencyPicksIngredients, + kahunaClientServiceUrls )) } def quotas = authentication { req => - Ok(views.html.quotas(config.mediaApiUri)) + Ok(views.html.quotas(config.mediaApiUri(instanceOf(req)))) } def notifications = authentication { req => diff --git a/kahuna/app/lib/KahunaClientServiceUrls.scala b/kahuna/app/lib/KahunaClientServiceUrls.scala new file mode 100644 index 00000000000..d2a2ec0c223 --- /dev/null +++ b/kahuna/app/lib/KahunaClientServiceUrls.scala @@ -0,0 +1,3 @@ +package lib + +case class KahunaClientServiceUrls(rootUri: String, mediaApiUri: String, authUri: String) diff --git a/kahuna/app/lib/KahunaConfig.scala b/kahuna/app/lib/KahunaConfig.scala index 1daa8623d4e..d4b65b73ecf 100644 --- a/kahuna/app/lib/KahunaConfig.scala +++ b/kahuna/app/lib/KahunaConfig.scala @@ -3,6 +3,7 @@ package lib import com.gu.mediaservice.lib.auth.Permissions.Pinboard import com.gu.mediaservice.lib.auth.SimplePermission import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance import play.api.libs.json._ case class ScriptToLoad( @@ -14,16 +15,14 @@ case class ScriptToLoad( ) class KahunaConfig(resources: GridConfigResources) extends CommonConfig(resources) { - val rootUri: String = services.kahunaBaseUri - val mediaApiUri: String = services.apiBaseUri - val authUri: String = services.authBaseUri + val rootUri: Instance => String = services.kahunaBaseUri + def mediaApiUri: Instance => String = services.apiBaseUri + val authUri: Instance => String = services.authBaseUri val sentryDsn: Option[String] = stringOpt("sentry.dsn").filterNot(_.isEmpty) val thumbOrigin: String = string("origin.thumb") - val fullOrigin: String = string("origin.full") val cropOrigin: String = string("origin.crops") - val imageOrigin: String = string("origin.images") val costFilterLabel: Option[String] = stringOpt("costFilter.label") val costFilterChargeable: Option[Boolean] = booleanOpt("costFilter.chargeable") diff --git a/kahuna/app/views/main.scala.html b/kahuna/app/views/main.scala.html index b09de2ed432..6591004c7e3 100644 --- a/kahuna/app/views/main.scala.html +++ b/kahuna/app/views/main.scala.html @@ -1,6 +1,7 @@ @import lib.ScriptToLoad @import lib.KahunaConfig +@import lib.KahunaClientServiceUrls @( reauthUri: String, fieldAliases: String, @@ -16,7 +17,8 @@ kahunaConfig: KahunaConfig, featureSwitches: String, imageTypes: String, - agencyPicksIngredients: String + agencyPicksIngredients: String, + kahunaClientServiceUrls: KahunaClientServiceUrls ) @@ -29,15 +31,15 @@ - - + + - + - + @kahunaConfig.sentryDsn.map { dsn => } @@ -53,7 +55,7 @@