media-api/app/lib/ImageResponse.scala (339 lines of code) (raw):
package lib
import com.gu.mediaservice.lib.argo.model._
import com.gu.mediaservice.lib.auth.{Internal, Tier}
import com.gu.mediaservice.lib.collections.CollectionsManager
import com.gu.mediaservice.lib.logging.GridLogging
import com.gu.mediaservice.model._
import com.gu.mediaservice.model.leases.{LeasesByMedia, MediaLease}
import com.gu.mediaservice.model.usage._
import lib.ImageResponse.extractAliasFieldValues
import lib.elasticsearch.SourceWrapper
import lib.usagerights.CostCalculator
import org.joda.time.DateTime
import play.api.libs.functional.syntax._
import play.api.libs.json._
import play.utils.UriEncoding
import java.net.URI
import scala.annotation.tailrec
import scala.util.{Failure, Try}
class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: UsageQuota)
extends EditsResponse with GridLogging {
implicit val usageQuotas: UsageQuota = usageQuota
object Costing extends CostCalculator {
override val freeSuppliers: List[String] = config.usageRightsConfig.freeSuppliers
override val suppliersCollectionExcl: Map[String, List[String]] = config.usageRightsConfig.suppliersCollectionExcl
val quotas = usageQuotas
}
val customSpecialInstructions: Map[String, String] = config.customSpecialInstructions
val customUsageRestrictions: Map[String, String] = config.customUsageRestrictions
implicit val costing: CostCalculator = Costing
val metadataBaseUri: String = config.services.metadataBaseUri
type FileMetadataEntity = EmbeddedEntity[FileMetadata]
type UsageEntity = EmbeddedEntity[Usage]
type UsagesEntity = EmbeddedEntity[List[UsageEntity]]
type MediaLeaseEntity = EmbeddedEntity[MediaLease]
type MediaLeasesEntity = EmbeddedEntity[LeasesByMedia]
private val imgPersistenceReasons = ImagePersistenceReasons(
config.maybePersistOnlyTheseCollections,
config.persistenceIdentifier
)
def imagePersistenceReasons(image: Image): List[String] = imgPersistenceReasons.reasons(image)
def canBeDeleted(image: Image) = image.canBeDeleted
def create(
id: String,
imageWrapper: SourceWrapper[Image],
withWritePermission: Boolean,
withDeleteImagePermission: Boolean,
withDeleteCropsOrUsagePermission: Boolean,
included: List[String] = List(), tier: Tier): (JsValue, List[Link], List[Action]) = {
val image = imageWrapper.instance
val source = Try {
Json.toJsObject(image)(imageResponseWrites(image.id, included.contains("fileMetadata"))) ++ imageWrapper.fields
}.recoverWith {
case e =>
logger.error(s"Failed to read ElasticSearch response $id into Image object: ${e.getMessage}")
Failure(e)
}.get
val pngFileUri = image.optimisedPng.map(_.file)
val fileUri = image.source.file
val imageUrl = s3Client.signUrl(config.imageBucket, fileUri, image, imageType = Source)
val pngUrl: Option[String] = pngFileUri
.map(s3Client.signUrl(config.imageBucket, _, image, imageType = OptimisedPng))
def s3SignedThumbUrl = s3Client.signUrl(config.thumbBucket, fileUri, image, imageType = Thumbnail)
val thumbUrl = config.cloudFrontDomainThumbBucket
.flatMap(s3Client.signedCloudFrontUrl(_, fileUri.getPath.drop(1)))
.getOrElse(s3SignedThumbUrl)
val validityMap = checkUsageRestrictions(source, ImageExtras.validityMap(image, withWritePermission))
val valid = ImageExtras.isValid(validityMap)
val invalidReasons = ImageExtras.invalidReasons(validityMap, config.customValidityDescription)
val downloadableMap = checkDownloadRestrictions(source, ImageExtras.downloadableMap(image, withWritePermission), withWritePermission)
val isDownloadable = ImageExtras.isValid(downloadableMap)
val persistenceReasons = imagePersistenceReasons(image)
val isPersisted = persistenceReasons.nonEmpty
val aliases = extractAliasFieldValues(config, imageWrapper)
val data = source.transform(addSecureSourceUrl(imageUrl))
.flatMap(_.transform(wrapUserMetadata(id)))
.flatMap(_.transform(addSecureThumbUrl(thumbUrl)))
.flatMap(_.transform(
pngUrl
.map(url => addSecureOptimisedPngUrl(url))
.getOrElse(__.json.pick)
))
.flatMap(_.transform(addValidity(valid)))
.flatMap(_.transform(addInvalidReasons(invalidReasons)))
.flatMap(_.transform(addUsageCost(source)))
.flatMap(_.transform(addPersistedState(isPersisted, persistenceReasons)))
.flatMap(_.transform(addSyndicationStatus(image)))
.flatMap(_.transform(addAliases(aliases)))
.flatMap(_.transform(addFromIndex(imageWrapper.fromIndex)))
.flatMap(_.transform(updateCustomSpecialInstructions(source)))
.flatMap(_.transform(updateCustomUsageRestrictions(source)))
.get
val links: List[Link] = tier match {
case Internal => imageLinks(id, imageUrl, pngUrl, withWritePermission, valid) ++ getDownloadLinks(id, isDownloadable)
case _ => List(downloadLink(id), downloadOptimisedLink(id))
}
val isDeletable = canBeDeleted(image) && withDeleteImagePermission
val actions: List[Action] = if (tier == Internal) imageActions(id, isDeletable, withWritePermission, withDeleteCropsOrUsagePermission) else Nil
(data, links, actions)
}
private def downloadLink(id: String) = Link("download", s"${config.rootUri}/images/$id/download")
private def downloadOptimisedLink(id: String) = Link("downloadOptimised", s"${config.rootUri}/images/$id/downloadOptimised?{&width,height,quality}")
private def getDownloadLinks(id: String, isDownloadable: Boolean): List[Link] = {
(config.restrictDownload, isDownloadable) match {
case (true, false) => Nil
case (_, _) => List(downloadLink(id), downloadOptimisedLink(id))
}
}
def imageLinks(id: String, secureUrl: String, securePngUrl: Option[String], withWritePermission: Boolean, valid: Boolean): List[Link] = {
import BoolImplicitMagic.BoolToOption
val cropLinkMaybe = valid.toOption(Link("crops", s"${config.cropperUri}/crops/$id"))
val editLinkMaybe = withWritePermission.toOption(Link("edits", s"${config.metadataUri}/metadata/$id"))
val optimisedPngLinkMaybe = securePngUrl map { case secureUrl => Link("optimisedPng", makeImgopsUri(new URI(secureUrl))) }
val optimisedLink = Link("optimised", makeImgopsUri(new URI(secureUrl)))
val imageLink = Link("ui:image", s"${config.kahunaUri}/images/$id")
val usageLink = Link("usages", s"${config.usageUri}/usages/media/$id")
val leasesLink = Link("leases", s"${config.leasesUri}/leases/media/$id")
val fileMetadataLink = Link("fileMetadata", s"${config.rootUri}/images/$id/fileMetadata")
val projectionLink = Link("loader", s"${config.loaderUri}/images/project/$id")
val projectionDiffLink = Link("api", s"${config.rootUri}/images/$id/projection/diff")
editLinkMaybe.toList ++ cropLinkMaybe.toList ++ optimisedPngLinkMaybe.toList ++
List(
optimisedLink, imageLink, usageLink, leasesLink, fileMetadataLink,
projectionLink, projectionDiffLink)
}
def imageActions(id: String, isDeletable: Boolean, withWritePermission: Boolean, withDeleteCropsOrUsagePermission: Boolean): List[Action] = {
val imageUri = URI.create(s"${config.rootUri}/images/$id")
val reindexUri = URI.create(s"${config.rootUri}/images/$id/reindex")
val addCollectionUri = URI.create(s"${config.collectionsUri}/images/$id")
val addLeaseUri = URI.create(s"${config.leasesUri}/leases")
val addLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id")
val replaceLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id")
val deleteLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id")
val deleteUsagesUri = URI.create(s"${config.usageUri}/usages/media/$id")
val deleteAction = Action("delete", imageUri, "DELETE")
val reindexAction = Action("reindex", reindexUri, "POST")
val addCollectionAction = Action("add-collection", addCollectionUri, "POST")
val addLeaseAction = Action("add-lease", addLeaseUri, "POST")
val addLeasesAction = Action("add-leases", addLeasesUri, "POST")
val replaceLeasesAction = Action("replace-leases", replaceLeasesUri, "PUT")
val deleteLeasesAction = Action("delete-leases", deleteLeasesUri, "DELETE")
val deleteUsagesAction = Action("delete-usages", deleteUsagesUri, "DELETE")
List(
deleteAction -> isDeletable,
reindexAction -> withWritePermission,
addLeaseAction -> withWritePermission,
addLeasesAction -> withWritePermission,
replaceLeasesAction -> withWritePermission,
deleteLeasesAction -> withWritePermission,
deleteUsagesAction -> withDeleteCropsOrUsagePermission,
addCollectionAction -> true
)
.filter { case (action, active) => active }
.map { case (action, active) => action }
}
def addUsageCost(source: JsValue): Reads[JsObject] = {
// We do the merge here as some records haven't had the user override applied
// to the root level `usageRights`
// TODO: Solve with reindex
val usageRights = List(
(source \ "usageRights").asOpt[JsObject],
(source \ "userMetadata" \ "usageRights").asOpt[JsObject]
).flatten.foldLeft(Json.obj())(_ ++ _).as[UsageRights]
val cost = Costing.getCost(usageRights)
__.json.update(__.read[JsObject].map(_ ++ Json.obj("cost" -> cost.toString)))
}
def addSyndicationStatus(image: Image): Reads[JsObject] = {
__.json.update(__.read[JsObject]).map(_ ++ Json.obj(
"syndicationStatus" -> image.syndicationStatus
))
}
def addPersistedState(isPersisted: Boolean, persistenceReasons: List[String]): Reads[JsObject] =
__.json.update(__.read[JsObject]).map(_ ++ Json.obj(
"persisted" -> Json.obj(
"value" -> isPersisted,
"reasons" -> persistenceReasons)))
def wrapUserMetadata(id: String): Reads[JsObject] =
__.read[JsObject].map { root =>
val edits = (root \ "userMetadata").asOpt[Edits].getOrElse(Edits.getEmpty)
val editsJson = Json.toJson(editsEmbeddedEntity(id, edits))
root ++ Json.obj("userMetadata" -> editsJson)
}
def addSecureSourceUrl(url: String): Reads[JsObject] =
(__ \ "source").json.update(__.read[JsObject].map(_ ++ Json.obj("secureUrl" -> url)))
def addSecureOptimisedPngUrl(url: String): Reads[JsObject] =
(__ \ "optimisedPng").json.update(__.read[JsObject].map(_ ++ Json.obj("secureUrl" -> url)))
def addSecureThumbUrl(url: String): Reads[JsObject] =
(__ \ "thumbnail").json.update(__.read[JsObject].map(_ ++ Json.obj("secureUrl" -> url)))
def addValidity(valid: Boolean): Reads[JsObject] =
__.json.update(__.read[JsObject]).map(_ ++ Json.obj("valid" -> valid))
def addFromIndex(fromIndex: String): Reads[JsObject] =
__.json.update(__.read[JsObject]).map(_ ++ Json.obj("fromIndex" -> fromIndex))
def addInvalidReasons(reasons: Map[String, String]): Reads[JsObject] =
__.json.update(__.read[JsObject]).map(_ ++ Json.obj("invalidReasons" -> Json.toJson(reasons)))
def addAliases(aliases: Seq[(String, JsValue)]): Reads[JsObject] =
__.json.update(__.read[JsObject]).map(_ ++ Json.obj(
"aliases" -> JsObject(aliases)
))
def makeImgopsUri(uri: URI): String =
config.imgopsUri + List(uri.getPath, uri.getRawQuery).mkString("?") + "{&w,h,q}"
private def updateCustomSpecialInstructions(source: JsValue): Reads[JsObject] = {
(source \ "usageRights" \ "category") match {
case JsDefined(category) =>
if (customSpecialInstructions.contains(category.as[String])) {
(__ \ "metadata").json.update(__.read[JsObject].map(_ ++ Json.obj(("usageInstructions") -> customSpecialInstructions.get(category.as[String]))))
} else {
__.json.update(__.read[JsObject])
}
case _ => __.json.update(__.read[JsObject])
}
}
private def updateCustomUsageRestrictions(source: JsValue): Reads[JsObject] = {
(source \ "usageRights" \ "category") match {
case JsDefined(category) =>
if (customUsageRestrictions.contains(category.as[String])) {
(__ \ "usageRights").json.update(__.read[JsObject].map(_ ++ Json.obj(("usageRestrictions") -> customUsageRestrictions.get(category.as[String]))))
} else {
__.json.update(__.read[JsObject])
}
case _ => __.json.update(__.read[JsObject])
}
}
private def checkUsageRestrictions(source: JsValue, validityMap: Map[String, ValidityCheck]) : Map[String, ValidityCheck] = {
(source \ "usageRights" \ "category") match {
case JsDefined(category) =>
if (customUsageRestrictions.contains(category.as[String])) {
validityMap.updated("conditional_paid", ValidityCheck(true, validityMap("conditional_paid").overrideable, validityMap("conditional_paid").shouldOverride))
} else {
validityMap
}
case _ => validityMap
}
}
private def checkDownloadRestrictions(source: JsValue, validityMap: Map[String, ValidityCheck], writePermissions: Boolean) : Map[String, ValidityCheck] = {
(source \ "usageRights" \ "category") match {
case JsDefined(category) =>
if (customUsageRestrictions.contains(category.as[String]) && !writePermissions) {
validityMap.updated("conditional_paid", ValidityCheck(true, validityMap("conditional_paid").overrideable, validityMap("conditional_paid").shouldOverride))
.updated("paid_image", ValidityCheck(true, validityMap("paid_image").overrideable, validityMap("paid_image").shouldOverride))
} else {
validityMap
}
case _ => validityMap
}
}
import play.api.libs.json.JodaWrites._
def imageResponseWrites(id: String, expandFileMetaData: Boolean): OWrites[Image] = (
(__ \ "id").write[String] ~
(__ \ "uploadTime").write[DateTime] ~
(__ \ "uploadedBy").write[String] ~
(__ \ "softDeletedMetadata").writeNullable[SoftDeletedMetadata] ~
(__ \ "lastModified").writeNullable[DateTime] ~
(__ \ "identifiers").write[Map[String, String]] ~
(__ \ "uploadInfo").write[UploadInfo] ~
(__ \ "source").write[Asset] ~
(__ \ "thumbnail").writeNullable[Asset] ~
(__ \ "optimisedPng").writeNullable[Asset] ~
(__ \ "fileMetadata").write[FileMetadataEntity]
.contramap(fileMetadataEntity(id, expandFileMetaData, _: FileMetadata)) ~
(__ \ "userMetadata").writeNullable[Edits] ~
(__ \ "metadata").write[ImageMetadata](ImageResponse.newlineNormalisingImageMetadataWriter) ~
(__ \ "originalMetadata").write[ImageMetadata] ~
(__ \ "usageRights").write[UsageRights] ~
(__ \ "originalUsageRights").write[UsageRights] ~
(__ \ "exports").write[List[Export]]
.contramap((crops: List[Crop]) => crops.map(Export.fromCrop(_: Crop))) ~
(__ \ "usages").write[UsagesEntity]
.contramap(usagesEntity(id, _: List[Usage])) ~
(__ \ "leases").write[MediaLeasesEntity]
.contramap(leasesEntity(id, _: LeasesByMedia)) ~
(__ \ "collections").write[List[EmbeddedEntity[CollectionResponse]]]
.contramap((collections: List[Collection]) => collections.map(c => collectionsEntity(id, c))) ~
(__ \ "syndicationRights").writeNullable[SyndicationRights] ~
(__ \ "usermetaDataLastModified").writeNullable[DateTime]
) (unlift(Image.unapply))
def fileMetaDataUri(id: String) = URI.create(s"${config.rootUri}/images/$id/fileMetadata")
def usagesUri(id: String) = URI.create(s"${config.usageUri}/usages/media/$id")
def usageUri(id: String) = {
URI.create(s"${config.usageUri}/usages/${UriEncoding.encodePathSegment(id, "UTF-8")}")
}
def leasesUri(id: String) = URI.create(s"${config.leasesUri}/leases/media/$id")
def usageEntity(usage: Usage) = EmbeddedEntity[Usage](usageUri(usage.id), Some(usage))
def usagesEntity(id: String, usages: List[Usage]) =
EmbeddedEntity[List[UsageEntity]](usagesUri(id), Some(usages.map(usageEntity)))
def leasesEntity(id: String, leaseByMedia: LeasesByMedia) =
EmbeddedEntity[LeasesByMedia](leasesUri(id), Some(leaseByMedia))
def collectionsEntity(id: String, c: Collection): EmbeddedEntity[CollectionResponse] =
collectionEntity(config.collectionsUri, id, c)
def collectionEntity(rootUri: String, imageId: String, c: Collection) = {
// TODO: Currently the GET for this URI does nothing
val uri = URI.create(s"$rootUri/images/$imageId/${CollectionsManager.pathToUri(c.path)}")
val response = CollectionResponse.build(c)
EmbeddedEntity(uri, Some(response), actions = List(
Action("remove", uri, "DELETE")
))
}
def fileMetadataEntity(id: String, expandFileMetaData: Boolean, fileMetadata: FileMetadata) = {
val displayableMetadata = if (expandFileMetaData) Some(fileMetadata) else None
EmbeddedEntity[FileMetadata](fileMetaDataUri(id), displayableMetadata)
}
}
object ImageResponse {
val newlineNormalisingImageMetadataWriter: Writes[ImageMetadata] = (input: ImageMetadata) => {
Json.toJson(normaliseNewLinesInImageMeta(input))
}
def normaliseNewLinesInImageMeta(imageMetadata: ImageMetadata): ImageMetadata = imageMetadata.copy(
description = imageMetadata.description.map(ImageResponse.normaliseNewlineChars),
copyright = imageMetadata.copyright.map(ImageResponse.normaliseNewlineChars),
specialInstructions = imageMetadata.specialInstructions.map(ImageResponse.normaliseNewlineChars),
suppliersReference = imageMetadata.suppliersReference.map(ImageResponse.normaliseNewlineChars),
)
private val pattern = """[\r\n]+""".r
def normaliseNewlineChars(string: String): String = pattern.replaceAllIn(string, "\n")
def canImgBeDeleted(image: Image) = !hasExports(image) && !hasUsages(image)
private def hasExports(image: Image) = image.exports.nonEmpty
private def hasUsages(image: Image) = image.usages.nonEmpty
def extractAliasFieldValues(config: MediaApiConfig, source: SourceWrapper[Image]): Seq[(String, JsValue)] = {
@tailrec
def nestedLookup(jsLookup: JsLookupResult, pathComponents: List[String]): JsLookupResult = {
pathComponents match {
case Nil => jsLookup
case head :: tail => nestedLookup(jsLookup \ head, tail)
}
}
config.fieldAliasConfigs.flatMap { config =>
val parts = config.elasticsearchPath.split('.').toList.filter(_.nonEmpty)
val lookupResult = nestedLookup(JsDefined(source.source), parts)
lookupResult.toOption.map {
config.alias -> _
}
}
}
}
// We're using this to slightly hydrate the json response
case class CollectionResponse private(path: List[String], pathId: String, description: String, cssColour: Option[String], actionData: ActionData)
object CollectionResponse {
implicit def writes: Writes[CollectionResponse] = Json.writes[CollectionResponse]
def build(c: Collection) =
CollectionResponse(c.path, c.pathId, c.description, CollectionsManager.getCssColour(c.path), c.actionData)
}
object BoolImplicitMagic {
// This functionality is a member of Option in scala 2.13
implicit class BoolToOption(val self: Boolean) extends AnyVal {
def toOption[A](value: => A): Option[A] = if (self) Some(value) else None
}
}