app/helpers/UserAvatarHelper.scala (93 lines of code) (raw):
package helpers
import akka.actor.ActorSystem
import akka.http.scaladsl.model.ContentTypes
import akka.stream.Materializer
import akka.stream.alpakka.s3.{S3Attributes, S3Headers}
import akka.stream.alpakka.s3.headers.CannedAcl
import akka.stream.alpakka.s3.scaladsl.S3
import akka.stream.scaladsl.{Sink, Source}
import akka.util.ByteString
import com.theguardian.multimedia.archivehunter.common.clientManagers.S3ClientManager
import org.slf4j.LoggerFactory
import play.api.Configuration
import software.amazon.awssdk.regions.Region
import java.net.{URI, URL}
import java.nio.ByteBuffer
import java.time.Instant
import java.util.Date
import javax.inject.{Inject, Singleton}
import scala.concurrent.Future
import scala.util.{Failure, Success, Try}
/**
* this class contains helper methods that allow for a user avatar to be written out to S3 and then passed
* to the frontend via a presigned link
* @param config server configuration object
* @param s3ClientManager S3ClientManager for communicating with S3
* @param system implicitly provided ActorSystem
* @param mat implicitly provided Materializer
*/
@Singleton
class UserAvatarHelper @Inject() (config:Configuration, s3ClientManager: S3ClientManager)(implicit system:ActorSystem, mat:Materializer) {
private val logger = LoggerFactory.getLogger(getClass)
import com.theguardian.multimedia.archivehunter.common.cmn_helpers.S3ClientExtensions._
private val s3Client = s3ClientManager.getClient(config.getOptional[String]("externalData.awsProfile"))
private val sanitiser = "[^\\w\\d-_]".r
protected def sanitisedKey(str: String):String = {
sanitiser.replaceAllIn(str, "_")
}
/**
* write the avatar data into S3 as an image for later retrieval.
* if the `content` ByteBuffer has not been flipped (i.e. position>0) then it is flipped here.
* @param username username associated with this avatar
* @param content data content to write
* @return a Future with the written ObjectMetadata, or a failed future if the configuration was not correct.
* catch this with .recover() or .onComplete()
*/
def writeAvatarData(username: String, content:ByteBuffer) = {
logger.debug(s"buffer current position ${content.position()} length ${content.remaining()} filename ${sanitisedKey(username)}")
if(content.position()!=0) content.flip()
val dataSource = Source
.fromIterator(()=>content.array().toIterator)
.map(ByteString.apply(_))
config.getOptional[String]("externalData.avatarBucket") match {
case Some(avatarBucket)=>
val credentials = s3ClientManager.getAlpakkaCredentials(config.getOptional[String]("externalData.awsProfile"))
S3.putObject(
avatarBucket,
sanitisedKey(username),
dataSource,
content.remaining(), //we should be at the start of the buffer
ContentTypes.NoContentType, //FIXME: should determine content type of buffer somehow
S3Headers.empty.withCannedAcl(CannedAcl.Private)
)
.withAttributes(S3Attributes.settings(credentials))
.runWith(Sink.head)
case None=>
logger.error("There is nothing configured for `externalData.avatarBucket` so user avatars will not work. Please update the configuration.")
Future.failed(new RuntimeException("Invalid configuration, please see server logs"))
}
}
/**
* try to get the avatar for a given name from the bucket.
* If successful, this will return a presigned URL suitable for client use directly to the S3 bucket.
* If unsuccessful, a message is logged and None is returned
* @param username the username to look up
* @return an Option containing a java.net.URL suitable for sending back to the client.
*/
def getAvatarUrl(username: String):Option[URL] = {
config.getOptional[String]("externalData.avatarBucket").flatMap(avatarBucket=> {
val credentials = s3ClientManager.newCredentialsProvider(config.getOptional[String]("externalData.awsProfile"))
val result = for {
rgn <- Try { Region.of(config.get[String]("externalData.awsRegion")) }
result <- s3Client.generatePresignedUrl(avatarBucket, sanitisedKey(username), 900, rgn, None, credentials)
} yield result
result match {
case Success(url)=>Some(url)
case Failure(err)=>
logger.error(s"Could not get avatar URL for user '$username': ${err.getMessage}", err)
None
}
})
}
/**
* get an S3 URL to the given user's avatar
* @param username
* @return
*/
def getAvatarLocation(username:String) = {
config.getOptional[String]("externalData.avatarBucket").map(avatarBucket=>{
new URI(s"s3://${avatarBucket}/${sanitisedKey(username)}")
})
}
def getAvatarLocationString(username:String) = getAvatarLocation(username).map(_.toString)
/**
* get a presigned URL from the S3 URL. This is for client usage
* @param s3Url the S3 URL to translate
* @return either a client-facing https presigned URL or a Failure indicating the problem
*/
def getPresignedUrl(s3Url:URI, overrideExpiry:Option[Int]=None):Try[URL] = {
config.getOptional[String]("externalData.avatarBucket") match {
case None=>
Failure(new RuntimeException("externalData.avatarBucket is not set, you need this in order to store user avatars"))
case Some(avatarBucket)=>
if(s3Url.getScheme!="s3") {
Failure(new RuntimeException("getPresignedUrl requires an S3 URL"))
} else if(s3Url.getHost!=avatarBucket){
Failure(new RuntimeException("incorrect bucket name"))
} else {
Try { Region.of(config.get[String]("externalData.awsRegion")) }.flatMap(rgn=> {
val credentials = s3ClientManager.newCredentialsProvider(config.getOptional[String]("externalData.awsProfile"))
val expiry = overrideExpiry.getOrElse(900) //link is valid for 15mins
s3Client.generatePresignedUrl(avatarBucket, s3Url.getPath.stripPrefix("/"), expiry, rgn, None, credentials)
})
}
}
}
}