175 lines
5.6 KiB
Scala
175 lines
5.6 KiB
Scala
package lila.memo
|
|
|
|
import akka.stream.scaladsl.{ FileIO, Source }
|
|
import akka.util.ByteString
|
|
import org.joda.time.DateTime
|
|
import play.api.libs.ws.StandaloneWSClient
|
|
import play.api.mvc.MultipartFormData
|
|
import reactivemongo.api.bson.Macros
|
|
import scala.concurrent.duration._
|
|
import scala.concurrent.ExecutionContext
|
|
|
|
import lila.common.config
|
|
import lila.db.dsl._
|
|
import com.github.blemale.scaffeine.LoadingCache
|
|
|
|
case class PicfitImage(
|
|
_id: PicfitImage.Id,
|
|
user: String,
|
|
// reverse reference like blog:id, streamer:id, coach:id, ...
|
|
// unique: a new image will delete the previous ones with same rel
|
|
rel: String,
|
|
name: String,
|
|
contentType: Option[String],
|
|
size: Int, // in bytes
|
|
createdAt: DateTime
|
|
) {
|
|
|
|
def id = _id
|
|
}
|
|
|
|
object PicfitImage {
|
|
|
|
case class Id(value: String) extends AnyVal with StringValue
|
|
|
|
implicit val imageIdBSONHandler = stringAnyValHandler[PicfitImage.Id](_.value, PicfitImage.Id.apply)
|
|
implicit val imageBSONHandler = Macros.handler[PicfitImage]
|
|
}
|
|
|
|
final class PicfitApi(coll: Coll, ws: StandaloneWSClient, config: PicfitConfig)(implicit
|
|
ec: ExecutionContext
|
|
) {
|
|
|
|
import PicfitApi._
|
|
private val uploadMaxBytes = uploadMaxMb * 1024 * 1024
|
|
|
|
def upload(rel: String, uploaded: Uploaded, userId: String): Fu[PicfitImage] =
|
|
if (uploaded.fileSize > uploadMaxBytes)
|
|
fufail(s"File size must not exceed ${uploadMaxMb}MB.")
|
|
else {
|
|
val image = PicfitImage(
|
|
_id = PicfitImage.Id(lila.common.ThreadLocalRandom nextString 10),
|
|
user = userId,
|
|
rel = rel,
|
|
name = sanitizeName(uploaded.filename),
|
|
contentType = uploaded.contentType,
|
|
size = uploaded.fileSize.toInt,
|
|
createdAt = DateTime.now
|
|
)
|
|
picfitServer.store(image, uploaded) >>
|
|
deletePrevious(image) >>
|
|
coll.insert.one(image) inject image
|
|
}
|
|
|
|
private def deletePrevious(image: PicfitImage): Funit =
|
|
coll
|
|
.findAndRemove($doc("rel" -> image.rel, "_id" $ne image.id))
|
|
.flatMap { _.result[PicfitImage] ?? picfitServer.delete }
|
|
.void
|
|
|
|
private def sanitizeName(name: String) = {
|
|
// the char `^` breaks play, even URL encoded
|
|
java.net.URLEncoder.encode(name, "UTF-8").replaceIf('%', "")
|
|
}
|
|
|
|
private object picfitServer {
|
|
|
|
def store(image: PicfitImage, from: Uploaded): Funit = {
|
|
type Part = MultipartFormData.FilePart[Source[ByteString, _]]
|
|
import WSBodyWritables._
|
|
val part: Part = MultipartFormData.FilePart(
|
|
key = "data",
|
|
filename = image.id.value,
|
|
contentType = from.contentType,
|
|
ref = FileIO.fromPath(from.ref.path),
|
|
fileSize = from.fileSize
|
|
)
|
|
val source: Source[Part, _] = Source(part :: List())
|
|
ws.url(s"${config.endpointPost}/upload")
|
|
.post(source)
|
|
.flatMap {
|
|
case res if res.status != 200 => fufail(s"${res.statusText} ${res.body take 200}")
|
|
case _ =>
|
|
lila.mon.picfit.uploadSize(image.user).record(image.size)
|
|
funit
|
|
}
|
|
.monSuccess(_.picfit.uploadTime(image.user))
|
|
}
|
|
|
|
def delete(image: PicfitImage): Funit =
|
|
ws.url(s"${config.endpointPost}/${image.id}").delete().flatMap {
|
|
case res if res.status != 200 =>
|
|
logger
|
|
.branch("picfit")
|
|
.error(s"deleteFromPicfit ${image.id} ${res.statusText} ${res.body take 200}")
|
|
funit
|
|
case _ => funit
|
|
}
|
|
}
|
|
}
|
|
|
|
object PicfitApi {
|
|
|
|
val uploadMaxMb = 4
|
|
|
|
type Uploaded = play.api.mvc.MultipartFormData.FilePart[play.api.libs.Files.TemporaryFile]
|
|
|
|
// from playframework/transport/client/play-ws/src/main/scala/play/api/libs/ws/WSBodyWritables.scala
|
|
object WSBodyWritables {
|
|
import play.api.libs.ws.BodyWritable
|
|
import play.api.libs.ws.SourceBody
|
|
import play.core.formatters.Multipart
|
|
implicit val bodyWritableOf_Multipart
|
|
: BodyWritable[Source[MultipartFormData.Part[Source[ByteString, _]], _]] = {
|
|
val boundary = Multipart.randomBoundary()
|
|
val contentType = s"multipart/form-data; boundary=$boundary"
|
|
BodyWritable(b => SourceBody(Multipart.transform(b, boundary)), contentType)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class PicfitUrl(endpoint: String, secretKey: config.Secret) {
|
|
|
|
// This operation will able you to resize the image to the specified width and height.
|
|
// Preserves the aspect ratio
|
|
def resize(
|
|
id: PicfitImage.Id,
|
|
size: Either[Int, Int], // either the width or the height! the other one will be preserved
|
|
upscale: Boolean = true
|
|
) = display(id, "resize")(
|
|
width = ~size.left.toOption,
|
|
height = ~size.toOption,
|
|
upscale = upscale
|
|
)
|
|
|
|
// Thumbnail scales the image up or down using the specified resample filter,
|
|
// crops it to the specified width and height and returns the transformed image.
|
|
// Preserves the aspect ratio
|
|
def thumbnail(
|
|
id: PicfitImage.Id,
|
|
width: Int,
|
|
height: Int,
|
|
upscale: Boolean = true
|
|
) = display(id, "thumbnail")(width, height, upscale)
|
|
|
|
private def display(id: PicfitImage.Id, operation: String)(
|
|
width: Int,
|
|
height: Int,
|
|
upscale: Boolean
|
|
) = {
|
|
// parameters must be given in alphabetical order for the signature to work (!)
|
|
val queryString = s"h=$height&op=$operation&path=$id&upscale=${upscale ?? 1}&w=$width"
|
|
s"$endpoint/display?${signQueryString(queryString)}"
|
|
}
|
|
|
|
private object signQueryString {
|
|
private val signer = com.roundeights.hasher.Algo hmac secretKey.value
|
|
private val cache: LoadingCache[String, String] =
|
|
CacheApi.scaffeineNoScheduler
|
|
.expireAfterWrite(5 minutes)
|
|
.build { qs => signer.sha1(qs).hex }
|
|
|
|
def apply(qs: String) = s"$qs&sig=${cache get qs}"
|
|
}
|
|
}
|