image-loader/app/model/Uploader.scala (359 lines of code) (raw):
package model
import com.gu.mediaservice.{GridClient, ImageDataMerger}
import com.gu.mediaservice.lib.Files.createTempFile
import java.io.File
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Paths}
import java.util.UUID
import com.gu.mediaservice.lib.argo.ArgoHelpers
import com.gu.mediaservice.lib.auth.Authentication
import com.gu.mediaservice.lib.auth.Authentication.Principal
import com.gu.mediaservice.lib.{BrowserViewableImage, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage}
import com.gu.mediaservice.lib.aws.{S3Object, UpdateMessage}
import com.gu.mediaservice.lib.cleanup.ImageProcessor
import com.gu.mediaservice.lib.formatting._
import com.gu.mediaservice.lib.imaging.ImageOperations
import com.gu.mediaservice.lib.imaging.ImageOperations.{optimisedMimeType, thumbMimeType}
import com.gu.mediaservice.lib.logging._
import com.gu.mediaservice.lib.metadata.{FileMetadataHelper, ImageMetadataConverter}
import com.gu.mediaservice.lib.net.URI
import com.gu.mediaservice.model._
import com.gu.mediaservice.syntax.MessageSubjects
import lib.{DigestedFile, ImageLoaderConfig, Notifications}
import lib.imaging.{FileMetadataReader, MimeTypeDetection}
import lib.storage.ImageLoaderStore
import model.Uploader.{fromUploadRequestShared, toImageUploadOpsCfg}
import model.upload.{OptimiseOps, OptimiseWithPngQuant, UploadRequest}
import org.joda.time.DateTime
import play.api.libs.json.{JsObject, Json}
import play.api.libs.ws.WSRequest
import scala.collection.compat._
import scala.concurrent.{ExecutionContext, Future}
case class ImageUpload(uploadRequest: UploadRequest, image: Image)
case object ImageUpload {
def createImage(uploadRequest: UploadRequest, source: Asset, thumbnail: Asset, png: Option[Asset],
fileMetadata: FileMetadata, metadata: ImageMetadata): Image = {
val usageRights = NoRights
Image(
uploadRequest.imageId,
uploadRequest.uploadTime,
uploadRequest.uploadedBy,
None,
Some(uploadRequest.uploadTime),
uploadRequest.identifiers,
uploadRequest.uploadInfo,
source,
Some(thumbnail),
png,
fileMetadata,
None,
metadata,
metadata,
usageRights,
usageRights,
List(),
List()
)
}
}
case class ImageUploadOpsCfg(
tempDir: File,
thumbWidth: Int,
thumbQuality: Double,
transcodedMimeTypes: List[MimeType],
originalFileBucket: String,
thumbBucket: String
)
case class ImageUploadOpsDependencies(
config: ImageUploadOpsCfg,
imageOps: ImageOperations,
storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object],
storeOrProjectThumbFile: StorableThumbImage => Future[S3Object],
storeOrProjectOptimisedImage: StorableOptimisedImage => Future[S3Object],
tryFetchThumbFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None),
tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None),
)
case class UploadStatusUri (uri: String) extends AnyVal {
def toJsObject = Json.obj("uri" -> uri)
}
object Uploader extends GridLogging {
def toImageUploadOpsCfg(config: ImageLoaderConfig): ImageUploadOpsCfg = {
ImageUploadOpsCfg(
config.tempDir,
config.thumbWidth,
config.thumbQuality,
config.transcodedMimeTypes,
config.imageBucket,
config.thumbnailBucket
)
}
def fromUploadRequestShared(uploadRequest: UploadRequest, deps: ImageUploadOpsDependencies, processor: ImageProcessor)
(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = {
import deps._
logger.info(logMarker, "Starting image ops")
val fileMetadataFuture = toFileMetadata(uploadRequest.tempFile, uploadRequest.imageId, uploadRequest.mimeType)
logger.info(logMarker, "Have read file headers")
fileMetadataFuture.flatMap(fileMetadata => {
uploadAndStoreImage(
storeOrProjectOriginalFile,
storeOrProjectThumbFile,
storeOrProjectOptimisedImage,
OptimiseWithPngQuant,
uploadRequest,
deps,
fileMetadata,
processor)(ec, addLogMarkers(fileMetadata.toLogMarker))
})
}
private[model] def uploadAndStoreImage(storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object],
storeOrProjectThumbFile: StorableThumbImage => Future[S3Object],
storeOrProjectOptimisedFile: StorableOptimisedImage => Future[S3Object],
optimiseOps: OptimiseOps,
uploadRequest: UploadRequest,
deps: ImageUploadOpsDependencies,
fileMetadata: FileMetadata,
processor: ImageProcessor)
(implicit ec: ExecutionContext, logMarker: LogMarker) = {
val originalMimeType = uploadRequest.mimeType
.orElse(MimeTypeDetection.guessMimeType(uploadRequest.tempFile).toOption)
match {
case Some(a) => a
case None => throw new Exception("File of unknown and undetectable mime type")
}
val tempDirForRequest: File = Files.createTempDirectory(deps.config.tempDir.toPath, "upload").toFile
val colourModelFuture = ImageOperations.identifyColourModel(uploadRequest.tempFile, originalMimeType)
val sourceDimensionsFuture = FileMetadataReader.dimensions(uploadRequest.tempFile, Some(originalMimeType))
val storableOriginalImage = StorableOriginalImage(
uploadRequest.imageId,
uploadRequest.tempFile,
originalMimeType,
uploadRequest.uploadTime,
toMetaMap(uploadRequest)
)
val sourceStoreFuture = storeOrProjectOriginalFile(storableOriginalImage)
val eventualBrowserViewableImage = createBrowserViewableFileFuture(uploadRequest, tempDirForRequest, deps)
val eventualImage = for {
browserViewableImage <- eventualBrowserViewableImage
s3Source <- sourceStoreFuture
mergedUploadRequest = patchUploadRequestWithS3Metadata(uploadRequest, s3Source)
optimisedFileMetadata <- FileMetadataReader.fromIPTCHeadersWithColorInfo(browserViewableImage)
thumbViewableImage <- createThumbFuture(optimisedFileMetadata, colourModelFuture, browserViewableImage, deps, tempDirForRequest)
s3Thumb <- storeOrProjectThumbFile(thumbViewableImage)
maybeStorableOptimisedImage <- getStorableOptimisedImage(
tempDirForRequest, optimiseOps, browserViewableImage, optimisedFileMetadata, deps.tryFetchOptimisedFile)
s3PngOption <- maybeStorableOptimisedImage match {
case Some(storableOptimisedImage) => storeOrProjectOptimisedFile(storableOptimisedImage).map(a=>Some(a))
case None => Future.successful(None)
}
sourceDimensions <- sourceDimensionsFuture
thumbDimensions <- FileMetadataReader.dimensions(thumbViewableImage.file, Some(thumbViewableImage.mimeType))
colourModel <- colourModelFuture
} yield {
val fullFileMetadata = fileMetadata.copy(colourModel = colourModel)
val metadata = ImageMetadataConverter.fromFileMetadata(fullFileMetadata, s3Source.metadata.objectMetadata.lastModified)
val sourceAsset = Asset.fromS3Object(s3Source, sourceDimensions)
val thumbAsset = Asset.fromS3Object(s3Thumb, thumbDimensions)
val pngAsset = s3PngOption.map(Asset.fromS3Object(_, sourceDimensions))
val baseImage = ImageUpload.createImage(mergedUploadRequest, sourceAsset, thumbAsset, pngAsset, fullFileMetadata, metadata)
val processedImage = processor(baseImage)
logger.info(logMarker, s"Ending image ops")
// FIXME: dirty hack to sync the originalUsageRights and originalMetadata as well
processedImage.copy(
originalMetadata = processedImage.metadata,
originalUsageRights = processedImage.usageRights
)
}
eventualImage.onComplete{ _ =>
tempDirForRequest.listFiles().map(f => f.delete())
tempDirForRequest.delete()
}
eventualImage
}
private def getStorableOptimisedImage(
tempDir: File,
optimiseOps: OptimiseOps,
browserViewableImage: BrowserViewableImage,
optimisedFileMetadata: FileMetadata,
tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]]
)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[StorableOptimisedImage]] = {
if (optimiseOps.shouldOptimise(Some(browserViewableImage.mimeType), optimisedFileMetadata)) {
for {
tempFile <- createTempFile("optimisedpng-", optimisedMimeType.fileExtension, tempDir)
maybeDownloadedOptimisedFile <- tryFetchOptimisedFile(browserViewableImage.id, tempFile)
(optimisedFile, optimisedMimeType) <- {
maybeDownloadedOptimisedFile match {
case Some(optData) => Future.successful(optData)
case None => optimiseOps.toOptimisedFile(browserViewableImage.file, browserViewableImage, tempFile)
}
}
} yield Some(
browserViewableImage.copy(
file = optimisedFile,
mimeType = optimisedMimeType
).asStorableOptimisedImage
)
} else if (browserViewableImage.isTransformedFromSource) {
Future.successful(Some(browserViewableImage.asStorableOptimisedImage))
} else
Future.successful(None)
}
def toMetaMap(uploadRequest: UploadRequest): Map[String, String] = {
val baseMeta = Map(
ImageStorageProps.uploadedByMetadataKey -> uploadRequest.uploadedBy,
ImageStorageProps.uploadTimeMetadataKey -> printDateTime(uploadRequest.uploadTime)
) ++
uploadRequest.identifiersMeta ++
uploadRequest.uploadInfo.filename.map(ImageStorageProps.filenameMetadataKey -> _)
baseMeta.view.mapValues(URI.encode).toMap
}
private def toFileMetadata(f: File, imageId: String, mimeType: Option[MimeType])(implicit logMarker: LogMarker): Future[FileMetadata] = {
mimeType match {
case Some(Png | Tiff | Jpeg) => FileMetadataReader.fromIPTCHeadersWithColorInfo(f, imageId, mimeType.get)
case _ => FileMetadataReader.fromIPTCHeaders(f, imageId)
}
}
private def createThumbFuture(fileMetadata: FileMetadata,
colourModelFuture: Future[Option[String]],
browserViewableImage: BrowserViewableImage,
deps: ImageUploadOpsDependencies,
tempDir: File,
)(implicit ec: ExecutionContext, logMarker: LogMarker) = {
import deps._
def generateThumbnail(tempFile: File) = {
for {
colourModel <- colourModelFuture
iccColourSpace = FileMetadataHelper.normalisedIccColourSpace(fileMetadata)
thumbData <- imageOps.createThumbnail(
browserViewableImage,
config.thumbWidth,
config.thumbQuality,
tempFile,
iccColourSpace,
colourModel,
)
} yield thumbData
}
for {
tempFile <- createTempFile(s"thumb-", thumbMimeType.fileExtension, tempDir)
maybeThumbFile <- deps.tryFetchThumbFile(browserViewableImage.id, tempFile)
(thumb, thumbMimeType) <- {
maybeThumbFile match {
case Some(thumbData) => Future.successful(thumbData)
case None => generateThumbnail(tempFile)
}
}
} yield browserViewableImage
.copy(file = thumb, mimeType = thumbMimeType)
.asStorableThumbImage
}
private def createBrowserViewableFileFuture(
uploadRequest: UploadRequest,
tempDir: File,
deps: ImageUploadOpsDependencies
)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[BrowserViewableImage] = {
import deps._
uploadRequest.mimeType match {
case Some(mime) if config.transcodedMimeTypes.contains(mime) =>
for {
(file, mimeType) <- imageOps.transformImage(uploadRequest.tempFile, uploadRequest.mimeType, tempDir)
} yield BrowserViewableImage(
uploadRequest.imageId,
file = file,
mimeType = mimeType,
isTransformedFromSource = true
)
case Some(mimeType) =>
Future.successful(
BrowserViewableImage(
uploadRequest.imageId,
file = uploadRequest.tempFile,
mimeType = mimeType)
)
case None => Future.failed(new Exception("This file is not an image with an identifiable mime type"))
}
}
def patchUploadRequestWithS3Metadata(request: UploadRequest, s3Object: S3Object): UploadRequest = {
val metadata = S3FileExtractedMetadata(s3Object.metadata.objectMetadata.lastModified.getOrElse(new DateTime), s3Object.metadata.userMetadata)
request.copy(
uploadTime = metadata.uploadTime,
uploadedBy = metadata.uploadedBy,
uploadInfo = request.uploadInfo.copy(filename = metadata.uploadFileName),
identifiers = metadata.identifiers
)
}
}
class Uploader(val store: ImageLoaderStore,
val config: ImageLoaderConfig,
val imageOps: ImageOperations,
val notifications: Notifications,
imageProcessor: ImageProcessor)
(implicit val ec: ExecutionContext) extends MessageSubjects with ArgoHelpers {
def fromUploadRequest(uploadRequest: UploadRequest)
(implicit logMarker: LogMarker): Future[ImageUpload] = {
val sideEffectDependencies = ImageUploadOpsDependencies(toImageUploadOpsCfg(config), imageOps,
storeSource, storeThumbnail, storeOptimisedImage)
val finalImage = fromUploadRequestShared(uploadRequest, sideEffectDependencies, imageProcessor)
finalImage.map(img => Stopwatch("finalImage"){ImageUpload(uploadRequest, img)})
}
private def storeSource(storableOriginalImage: StorableOriginalImage)
(implicit logMarker: LogMarker) = store.store(storableOriginalImage)
private def storeThumbnail(storableThumbImage: StorableThumbImage)
(implicit logMarker: LogMarker) = store.store(storableThumbImage)
private def storeOptimisedImage(storableOptimisedImage: StorableOptimisedImage)
(implicit logMarker: LogMarker) = store.store(storableOptimisedImage)
def loadFile(digestedFile: DigestedFile,
uploadedBy: String,
identifiers: Option[String],
uploadTime: DateTime,
filename: Option[String])
(implicit ec:ExecutionContext,
logMarker: LogMarker): Future[UploadRequest] = Future {
val DigestedFile(tempFile, id) = digestedFile
// TODO: should error if the JSON parsing failed
val identifiersMap = identifiers
.map(Json.parse(_).as[Map[String, String]])
.getOrElse(Map.empty)
.view
.mapValues(_.toLowerCase)
.toMap
MimeTypeDetection.guessMimeType(tempFile) match {
case util.Left(unsupported) =>
logger.error(logMarker, s"Unsupported mimetype", unsupported)
throw unsupported
case util.Right(mimeType) =>
logger.info(logMarker, s"Detected mimetype as $mimeType")
UploadRequest(
imageId = id,
tempFile = tempFile,
mimeType = Some(mimeType),
uploadTime = uploadTime,
uploadedBy = uploadedBy,
identifiers = identifiersMap,
uploadInfo = UploadInfo(filename)
)
}
}
def storeFile(uploadRequest: UploadRequest)
(implicit ec:ExecutionContext,
logMarker: LogMarker): Future[UploadStatusUri] = {
logger.info(logMarker, "Storing file")
for {
imageUpload <- fromUploadRequest(uploadRequest)
updateMessage = UpdateMessage(subject = Image, image = Some(imageUpload.image))
_ <- Future { notifications.publish(updateMessage) }
// TODO: centralise where all these URLs are constructed
} yield UploadStatusUri(s"${config.rootUri}/uploadStatus/${uploadRequest.imageId}")
}
def restoreFile(uploadRequest: UploadRequest,
gridClient: GridClient,
onBehalfOfFn: WSRequest => WSRequest)
(implicit ec: ExecutionContext,
logMarker: LogMarker): Future[Unit] = for {
imageUpload <- fromUploadRequest(uploadRequest)
imageWithoutUserEdits = imageUpload.image
imageWithUserEditsApplied <- ImageDataMerger.aggregate(imageWithoutUserEdits, gridClient, onBehalfOfFn)
_ <- Future {
notifications.publish(
UpdateMessage(subject = Image, image = Some(imageWithUserEditsApplied))
)
}
} yield ()
}