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.
persistence.identifierpicdarUrn for Guardian)persistence.onlyTheseCollections