media-api/app/controllers/MediaApi.scala (461 lines of code) (raw):

package controllers import org.apache.pekko.stream.scaladsl.StreamConverters import com.google.common.net.HttpHeaders import com.gu.mediaservice.{GridClient, JsonDiff} import com.gu.mediaservice.lib.argo._ import com.gu.mediaservice.lib.argo.model.{Action, _} import com.gu.mediaservice.lib.auth.Authentication.{Request, _} import com.gu.mediaservice.lib.auth.Permissions.{ArchiveImages, DeleteCropsOrUsages, EditMetadata, UploadImages, DeleteImage => DeleteImagePermission} import com.gu.mediaservice.lib.auth._ import com.gu.mediaservice.lib.aws.{ContentDisposition, ThrallMessageSender, UpdateMessage} import com.gu.mediaservice.lib.config.Services import com.gu.mediaservice.lib.formatting.printDateTime import com.gu.mediaservice.lib.logging.MarkerMap import com.gu.mediaservice.lib.metadata.SoftDeletedMetadataTable import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects import lib._ import lib.elasticsearch._ import org.apache.http.entity.ContentType import org.http4s.UriTemplate import org.joda.time.{DateTime, DateTimeZone} import play.api.http.HttpEntity import play.api.libs.json._ import play.api.libs.ws.WSClient import play.api.mvc.Security.AuthenticatedRequest import play.api.mvc._ import java.net.URI import scala.concurrent.{ExecutionContext, Future} import scala.util.Try class MediaApi( auth: Authentication, messageSender: ThrallMessageSender, softDeletedMetadataTable: SoftDeletedMetadataTable, elasticSearch: ElasticSearch, imageResponse: ImageResponse, config: MediaApiConfig, override val controllerComponents: ControllerComponents, s3Client: S3Client, mediaApiMetrics: MediaApiMetrics, ws: WSClient, authorisation: Authorisation )(implicit val ec: ExecutionContext) extends BaseController with MessageSubjects with ArgoHelpers with ContentDisposition { val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty) val gridClient: GridClient = GridClient(services)(ws) private val searchParamList = List( "q", "ids", "offset", "length", "orderBy", "since", "until", "modifiedSince", "modifiedUntil", "takenSince", "takenUntil", "uploadedBy", "archived", "valid", "free", "payType", "hasExports", "hasIdentifier", "missingIdentifier", "hasMetadata", "persisted", "usageStatus", "usagePlatform", "hasRightsAcquired", "syndicationStatus", "countAll", "persisted" ).mkString(",") private val searchLinkHref = s"${config.rootUri}/images{?$searchParamList}" private val searchLink = Link("search", searchLinkHref) private def indexResponse(user: Principal) = { val indexData = Json.obj( "description" -> "This is the Media API" // ^ Flatten None away ) val userCanUpload: Boolean = authorisation.hasPermissionTo(UploadImages)(user) val userCanArchive: Boolean = authorisation.hasPermissionTo(ArchiveImages)(user) val maybeLoaderLink: Option[Link] = Some(Link("loader", config.loaderUri)).filter(_ => userCanUpload) val maybeArchiveLink: Option[Link] = Some(Link("archive", s"${config.metadataUri}/metadata/{id}/archived")).filter(_ => userCanArchive) val indexLinks = List( searchLink, Link("image", s"${config.rootUri}/images/{id}"), // FIXME: credit is the only field available for now as it's the only on // that we are indexing as a completion suggestion Link("metadata-search", s"${config.rootUri}/suggest/metadata/{field}{?q}"), Link("label-search", s"${config.rootUri}/images/edits/label{?q}"), Link("cropper", config.cropperUri), Link("edits", config.metadataUri), Link("session", s"${config.authUri}/session"), Link("witness-report", s"${config.services.guardianWitnessBaseUri}/2/report/{id}"), Link("collections", config.collectionsUri), Link("permissions", s"${config.rootUri}/permissions"), Link("leases", config.leasesUri), Link("syndicate-image", s"${config.rootUri}/images/{id}/{partnerName}/{startPending}/syndicateImage"), Link("undelete", s"${config.rootUri}/images/{id}/undelete") ) ++ maybeLoaderLink.toList ++ maybeArchiveLink.toList respond(indexData, indexLinks) } private def ImageCannotBeDeleted = respondError(MethodNotAllowed, "cannot-delete", "Cannot delete persisted images") private def ImageDeleteForbidden = respondError(Forbidden, "delete-not-allowed", "No permission to delete this image") private def ImageEditForbidden = respondError(Forbidden, "edit-not-allowed", "No permission to edit this image") private def ImageNotFound(id: String) = respondError(NotFound, "image-not-found", s"No image found with the given id $id") private def ExportNotFound = respondError(NotFound, "export-not-found", "No export found with the given id") def index = auth { request => indexResponse(request.user) } def getIncludedFromParams(request: AuthenticatedRequest[AnyContent, Principal]): List[String] = { val includedQuery: Option[String] = request.getQueryString("include") includedQuery.map(_.split(",").map(_.trim).toList).getOrElse(List()) } def canUserDeleteCropsOrUsages(principal: Principal): Boolean = authorisation.hasPermissionTo(DeleteCropsOrUsages)(principal) private def isAvailableForSyndication(image: Image): Boolean = image.syndicationRights.exists(_.isAvailableForSyndication) private def hasPermission(principal: Principal, image: Image): Boolean = principal.accessor.tier match { case Syndication => isAvailableForSyndication(image) case _ => true } def getImage(id: String) = auth.async { request => getImageResponseFromES(id, request) map { case Some((_, imageData, imageLinks, imageActions)) => respond(imageData, imageLinks, imageActions) case _ => ImageNotFound(id) } } /** * Get the raw response from ElasticSearch. */ def getImageFromElasticSearch(id: String) = auth.async { request => getImageResponseFromES(id, request) map { case Some((source, _, imageLinks, imageActions)) => respond(source, imageLinks, imageActions) case _ => ImageNotFound(id) } } def uploadedBy(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageUploaderById(id) map { case Some(uploadedBy) => respond(uploadedBy) case _ => ImageNotFound(id) } } def diffProjection(id: String) = auth.async { request => val onBehalfOfFn: OnBehalfOfPrincipal = auth.getOnBehalfOfPrincipal(request.user) for { maybeEsImage <- getImageResponseFromES(id, request) maybeEsJson = maybeEsImage.map{ case (source, _, _, _) => Json.toJson(source) } maybeProjectedImage <- gridClient.getImageLoaderProjection(id, onBehalfOfFn) maybeProjectedJson = maybeProjectedImage.map(Json.toJson(_)) } yield { (maybeEsJson, maybeProjectedJson) match { case (None, None) => ImageNotFound(id) case (es, projected) => respond(JsonDiff.diff( es.getOrElse(JsObject.empty), projected.getOrElse(JsObject.empty) )) } } } def getImageFileMetadata(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val links = List( Link("image", s"${config.rootUri}/images/$id") ) respond(Json.toJson(image.fileMetadata), links) case _ => ImageNotFound(id) } } def getImageExports(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val links = List( Link("image", s"${config.rootUri}/images/$id") ) respond(Json.toJson(image.exports), links) case _ => ImageNotFound(id) } } def getImageExport(imageId: String, exportId: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(imageId) map { case Some(source) if hasPermission(request.user, source) => val exportOption = source.exports.find(_.id.contains(exportId)) exportOption.foldLeft(ExportNotFound)((memo, export) => respond(export)) case _ => ImageNotFound(imageId) } } def getSoftDeletedMetadata(id: String) = auth.async { softDeletedMetadataTable.getStatus(id) .map { case Some(scala.Right(record)) => respond(record) case Some(Left(error)) => respondError(BadRequest, "cannot-get", s"Cannot get soft-deleted metadata ${error}") case None => respondNotFound(s"No soft-deleted metadata found for image id: ${id}") } .recover{ case error => respondError(InternalServerError, "cannot-get", s"Cannot get soft-deleted metadata ${error}") } } def downloadImageExport(imageId: String, exportId: String, width: Int) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(imageId) map { case Some(source) if hasPermission(request.user, source) => val maybeResult = for { export <- source.exports.find(_.id.contains(exportId)) asset <- export.assets.find(_.dimensions.exists(_.width == width)) s3Object <- Try(s3Client.getObject(config.imgPublishingBucket, asset.file)).toOption file = StreamConverters.fromInputStream(() => s3Object.getObjectContent) entity = HttpEntity.Streamed(file, asset.size, asset.mimeType.map(_.name)) result = Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(source, export, asset, config.shortenDownloadFilename)) } yield { if(config.recordDownloadAsUsage) { postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), source.id, Authentication.getIdentity(request.user)) } result } maybeResult.getOrElse(ExportNotFound) case _ => ImageNotFound(imageId) } } def hardDeleteImage(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val imageCanBeDeleted = imageResponse.canBeDeleted(image) if (imageCanBeDeleted) { val canDelete = authorisation.isUploaderOrHasPermission(request.user, image.uploadedBy, DeleteImagePermission) if (canDelete) { val updateMessage = UpdateMessage(subject = DeleteImage, id = Some(id)) messageSender.publish(updateMessage) Accepted } else { ImageDeleteForbidden } } else { ImageCannotBeDeleted } case _ => ImageNotFound(id) } } def deleteImage(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val imageCanBeDeleted = imageResponse.canBeDeleted(image) if (imageCanBeDeleted){ val canDelete = authorisation.isUploaderOrHasPermission(request.user, image.uploadedBy, DeleteImagePermission) if(canDelete){ val imageStatusRecord = ImageStatusRecord(id, request.user.accessor.identity, DateTime.now(DateTimeZone.UTC).toString, true) softDeletedMetadataTable.setStatus(imageStatusRecord) .map { _ => messageSender.publish( UpdateMessage( subject = SoftDeleteImage, id = Some(id), softDeletedMetadata = Some(SoftDeletedMetadata( deleteTime = DateTime.now(DateTimeZone.UTC), deletedBy = request.user.accessor.identity )) ) ) } Accepted } else { ImageDeleteForbidden } } else { ImageCannotBeDeleted } case _ => ImageNotFound(id) } } def unSoftDeleteImage(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val canDelete = authorisation.isUploaderOrHasPermission(request.user, image.uploadedBy, DeleteImagePermission) if(canDelete){ softDeletedMetadataTable.updateStatus(id, false) .map { _ => messageSender.publish( UpdateMessage( subject = UnSoftDeleteImage, id = Some(id) ) ) } Accepted } else { ImageDeleteForbidden } case _ => ImageNotFound(id) } } def downloadOriginalImage(id: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) flatMap { case Some(image) if hasPermission(request.user, image) => { val apiKey = request.user.accessor logger.info(s"Download original image: $id from user: ${Authentication.getIdentity(request.user)}", apiKey, id) mediaApiMetrics.incrementImageDownload(apiKey, mediaApiMetrics.OriginalDownloadType) val s3Object = s3Client.getObject(config.imageBucket, image.source.file) val file = StreamConverters.fromInputStream(() => s3Object.getObjectContent) val entity = HttpEntity.Streamed(file, image.source.size, image.source.mimeType.map(_.name)) if(config.recordDownloadAsUsage) { postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) } Future.successful( Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(image, Source, config.shortenDownloadFilename)) ) } case _ => Future.successful(ImageNotFound(id)) } } def syndicateImage(id: String, partnerName: String, startPending: String) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) flatMap { case Some(image) if hasPermission(request.user, image) => { val apiKey = request.user.accessor logger.info(s"Syndicate image: $id from user: ${Authentication.getIdentity(request.user)}", apiKey, id, partnerName, startPending) postToUsages(config.usageUri + "/usages/syndication", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user), Option(partnerName), Option(startPending)) Future.successful(Ok) } case _ => { Future.successful(ImageNotFound(id))} } } def downloadOptimisedImage(id: String, width: Integer, height: Integer, quality: Integer) = auth.async { request => implicit val r: Request[AnyContent] = request elasticSearch.getImageById(id) flatMap { case Some(image) if hasPermission(request.user, image) => { val apiKey = request.user.accessor logger.info(s"Download optimised image: $id from user: ${Authentication.getIdentity(request.user)}", apiKey, id) mediaApiMetrics.incrementImageDownload(apiKey, mediaApiMetrics.OptimisedDownloadType) val sourceImageUri = new URI(s3Client.signUrl(config.imageBucket, image.optimisedPng.getOrElse(image.source).file, image, imageType = image.optimisedPng match { case Some(_) => OptimisedPng case _ => Source })) if(config.recordDownloadAsUsage) { postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) } Future.successful( Redirect(config.imgopsUri + List(sourceImageUri.getPath, sourceImageUri.getRawQuery).mkString("?") + s"&w=$width&h=$height&q=$quality") ) } case _ => Future.successful(ImageNotFound(id)) } } def postToUsages(uri: String, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal, mediaId: String, user: String, partnerName: Option[String] = None, startPending: Option[String] = None) = { val baseRequest = ws.url(uri) .withHttpHeaders(Authentication.originalServiceHeaderName -> config.appName, HttpHeaders.ORIGIN -> config.rootUri, HttpHeaders.CONTENT_TYPE -> ContentType.APPLICATION_JSON.getMimeType) val request = onBehalfOfPrincipal(baseRequest) val usagesMetadata = uri match { case s if s.contains("download") => Map("mediaId" -> mediaId, "dateAdded" -> printDateTime(DateTime.now()), "downloadedBy" -> user) case s if s.contains("syndication") => Map("mediaId" -> mediaId, "dateAdded" -> printDateTime(DateTime.now()), "syndicatedBy" -> user, "startPending" -> startPending.getOrElse("false"), "partnerName" -> partnerName.getOrElse( throw new IllegalArgumentException("partnerName required for SyndicationUsageRequest")) ) } logger.info(s"Making usages request to $uri") request.post(Json.toJson(Map("data" -> usagesMetadata))) //fire and forget } def imageSearch() = auth.async { request => implicit val r: Request[AnyContent] = request val shouldFlagGraphicImages = request.cookies.get("SHOULD_BLUR_GRAPHIC_IMAGES") .map(_.value).getOrElse(config.defaultShouldBlurGraphicImages.toString) == "true" implicit val logMarker: MarkerMap = MarkerMap( "shouldFlagGraphicImages" -> shouldFlagGraphicImages, "user" -> r.user.accessor.identity ) val include = getIncludedFromParams(request) def hitToImageEntity(elasticId: String, image: SourceWrapper[Image]): EmbeddedEntity[JsValue] = { val writePermission = authorisation.isUploaderOrHasPermission(request.user, image.instance.uploadedBy, EditMetadata) val deletePermission = authorisation.isUploaderOrHasPermission(request.user, image.instance.uploadedBy, DeleteImagePermission) val deleteCropsOrUsagePermission = canUserDeleteCropsOrUsages(request.user) val (imageData, imageLinks, imageActions) = imageResponse.create(elasticId, image, writePermission, deletePermission, deleteCropsOrUsagePermission, include, request.user.accessor.tier) val id = (imageData \ "id").as[String] val imageUri = URI.create(s"${config.rootUri}/images/$id") EmbeddedEntity(uri = imageUri, data = Some(imageData), imageLinks, imageActions) } def respondSuccess(searchParams: SearchParams) = for { SearchResults(hits, totalCount, maybeOrgOwnedCount) <- elasticSearch.search( searchParams.copy( shouldFlagGraphicImages = shouldFlagGraphicImages, ) ) imageEntities = hits map (hitToImageEntity _).tupled prevLink = getPrevLink(searchParams) nextLink = getNextLink(searchParams, totalCount) links = List(prevLink, nextLink).flatten } yield respondCollection(imageEntities, Some(searchParams.offset), Some(totalCount), maybeOrgOwnedCount, links) val _searchParams = SearchParams(request) val hasDeletePermission = authorisation.isUploaderOrHasPermission(request.user, "", DeleteImagePermission) val canViewDeletedImages = _searchParams.query.contains("is:deleted") && !hasDeletePermission val searchParams = if(canViewDeletedImages) _searchParams.copy(uploadedBy = Some(Authentication.getIdentity(request.user))) else _searchParams SearchParams.validate(searchParams).fold( // TODO: respondErrorCollection? errors => Future.successful(respondError(UnprocessableEntity, InvalidUriParams.errorKey, errors.map(_.message).mkString(", ")) ), params => respondSuccess(params) ) } private def getImageResponseFromES(id: String, request: Authentication.Request[AnyContent]): Future[Option[(Image, JsValue, List[Link], List[Action])]] = { implicit val r: Authentication.Request[AnyContent] = request val include = getIncludedFromParams(request) elasticSearch.getImageWithSourceById(id) map { case Some(source) if hasPermission(request.user, source.instance) => val writePermission = authorisation.isUploaderOrHasPermission(request.user, source.instance.uploadedBy, EditMetadata) val deleteImagePermission = authorisation.isUploaderOrHasPermission(request.user, source.instance.uploadedBy, DeleteImagePermission) val deleteCropsOrUsagePermission = canUserDeleteCropsOrUsages(request.user) val (imageData, imageLinks, imageActions) = imageResponse.create( id, source, writePermission, deleteImagePermission, deleteCropsOrUsagePermission, include, request.user.accessor.tier ) Some((source.instance, imageData, imageLinks, imageActions)) case _ => None } } private def getSearchUrl(searchParams: SearchParams, updatedOffset: Int, length: Int): String = { // Enforce a toDate to exclude new images since the current request val toDate = searchParams.until.getOrElse(DateTime.now) val paramMap: Map[String, String] = SearchParams.toStringMap(searchParams) ++ Map( "offset" -> updatedOffset.toString, "length" -> length.toString, "toDate" -> printDateTime(toDate) ) paramMap.foldLeft(UriTemplate()){ (acc, pair) => acc.expandAny(pair._1, pair._2)}.toString } private def getPrevLink(searchParams: SearchParams): Option[Link] = { val prevOffset = List(searchParams.offset - searchParams.length, 0).max if (searchParams.offset > 0) { // adapt length to avoid overlapping with current val prevLength = List(searchParams.length, searchParams.offset - prevOffset).min val prevUrl = getSearchUrl(searchParams, prevOffset, prevLength) Some(Link("prev", prevUrl)) } else { None } } private def getNextLink(searchParams: SearchParams, totalCount: Long): Option[Link] = { val nextOffset = searchParams.offset + searchParams.length if (nextOffset < totalCount) { val nextUrl = getSearchUrl(searchParams, nextOffset, searchParams.length) Some(Link("next", nextUrl)) } else { None } } }