|
Play Framework/Scala example source code file (Assets.scala)
The Assets.scala Play Framework example source code
/*
* Copyright (C) 2009-2013 Typesafe Inc. <http://www.typesafe.com>
*/
package controllers
import play.api._
import play.api.mvc._
import play.api.libs._
import play.api.libs.iteratee._
import java.io._
import java.net.{ URL, JarURLConnection }
import org.joda.time.format.{ DateTimeFormatter, DateTimeFormat }
import org.joda.time.DateTimeZone
import play.utils.{ InvalidUriEncodingException, UriEncoding }
import scala.concurrent.{ ExecutionContext, Promise, Future, blocking }
import scala.util.control.NonFatal
import scala.util.{ Success, Failure }
import java.util.Date
import play.api.libs.iteratee.Execution.Implicits
import play.api.http.ContentTypes
import scala.collection.concurrent.TrieMap
import play.core.Router.ReverseRouteContext
import scala.io.Source
/*
* A map designed to prevent the "thundering herds" issue.
*
* This could be factored out into its own thing, improved and made available more widely. We could also
* use spray-cache once it has been re-worked into the Akka code base.
*
* The essential mechanics of the cache are that all asset requests are remembered, unless their lookup fails or if
* the asset doesn't exist, in which case we don't remember them in order to avoid an exploit where we would otherwise
* run out of memory.
*
* The population function is executed using the passed-in execution context
* which may mean that it is on a separate thread thus permitting long running operations to occur. Other threads
* requiring the same resource will be given the future of the result immediately.
*
* There are no explicit bounds on the cache as it isn't considered likely that the number of distinct asset requests would
* result in an overflow of memory. Bounds are implied given the number of distinct assets that are available to be
* served by the project.
*
* Instead of a SelfPopulatingMap, a better strategy would be to furnish the assets controller with all of the asset
* information on startup. This shouldn't be that difficult as sbt-web has that information available. Such an
* approach would result in an immutable map being used which in theory should be faster.
*/
private class SelfPopulatingMap[K, V] {
private val store = TrieMap[K, Future[Option[V]]]()
def putIfAbsent(k: K)(pf: K => Option[V])(implicit ec: ExecutionContext): Future[Option[V]] = {
lazy val p = Promise[Option[V]]()
store.putIfAbsent(k, p.future) match {
case Some(f) => f
case None =>
val f = Future(pf(k))(ec.prepare())
f.onComplete {
case Failure(_) | Success(None) => store.remove(k)
case _ => // Do nothing, the asset was successfully found and is now cached
}
p.completeWith(f)
p.future
}
}
}
/*
* Retains meta information regarding an asset that can be readily cached.
*/
private[controllers] object AssetInfo {
def config[T](lookup: Configuration => Option[T]): Option[T] = for {
app <- Play.maybeApplication
value <- lookup(app.configuration)
} yield value
def isDev = Play.maybeApplication.fold(false)(_.mode == Mode.Dev)
def isProd = Play.maybeApplication.fold(false)(_.mode == Mode.Prod)
def resource(name: String): Option[URL] = for {
app <- Play.maybeApplication
resource <- app.resource(name)
} yield resource
lazy val defaultCharSet = config(_.getString("default.charset")).getOrElse("utf-8")
lazy val defaultCacheControl = config(_.getString("assets.defaultCache")).getOrElse("public, max-age=3600")
lazy val aggressiveCacheControl = config(_.getString("assets.aggressiveCache")).getOrElse("public, max-age=31536000")
lazy val digestAlgorithm = config(_.getString("assets.digest.algorithm")).getOrElse("md5")
val timeZoneCode = "GMT"
val parsableTimezoneCode = " " + timeZoneCode
val df: DateTimeFormatter =
DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss '" + timeZoneCode + "'").withLocale(java.util.Locale.ENGLISH).withZone(DateTimeZone.forID(timeZoneCode))
val dfp: DateTimeFormatter =
DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss").withLocale(java.util.Locale.ENGLISH).withZone(DateTimeZone.forID(timeZoneCode))
/*
* jodatime does not parse timezones, so we handle that manually
*/
def parseDate(date: String): Option[Date] = try {
val d = dfp.parseDateTime(date.replace(parsableTimezoneCode, "")).toDate
Some(d)
} catch {
case e: IllegalArgumentException =>
Logger.debug(s"An invalidate date was received: $date", e)
None
}
}
/*
* Retain meta information regarding an asset.
*/
private[controllers] class AssetInfo(
val name: String,
val url: URL,
val gzipUrl: Option[URL],
val digest: Option[String]) {
import AssetInfo._
def addCharsetIfNeeded(mimeType: String): String =
if (MimeTypes.isText(mimeType)) s"$mimeType; charset=$defaultCharSet" else mimeType
val configuredCacheControl = config(_.getString("\"assets.cache." + name + "\""))
def cacheControl(aggressiveCaching: Boolean): String = {
configuredCacheControl.getOrElse {
if (isProd) {
if (aggressiveCaching) aggressiveCacheControl else defaultCacheControl
} else {
"no-cache"
}
}
}
val lastModified: Option[String] = url.getProtocol match {
case "file" => Some(df.print(new File(url.getPath).lastModified))
case "jar" =>
Option(url.openConnection).map {
case jarUrlConnection: JarURLConnection =>
try {
jarUrlConnection.getJarEntry.getTime
} finally {
jarUrlConnection.getInputStream.close()
}
}.filterNot(_ == -1).map(df.print)
case _ => None
}
val etag: Option[String] = digest.orElse(lastModified.map(_ + " -> " + url.toExternalForm).map("\"" + Codecs.sha1(_) + "\""))
val mimeType: String = MimeTypes.forFileName(name).fold(ContentTypes.BINARY)(addCharsetIfNeeded)
val parsedLastModified = lastModified.flatMap(parseDate)
def url(gzipAvailable: Boolean): URL = {
gzipUrl match {
case Some(x) => if (gzipAvailable) x else url
case None => url
}
}
}
/**
* Controller that serves static resources.
*
* Resources are searched in the classpath.
*
* It handles Last-Modified and ETag header automatically.
* If a gzipped version of a resource is found (Same resource name with the .gz suffix), it is served instead. If a
* digest file is available for a given asset then its contents are read and used to supply a digest value. This value will be used for
* serving up ETag values and for the purposes of reverse routing. For example given "a.js", if there is an "a.js.md5"
* file available then the latter contents will be used to determine the Etag value.
* The reverse router also uses the digest in order to translate any file to the form <digest>-<asset> for
* example "a.js" may be also found at "d41d8cd98f00b204e9800998ecf8427e-a.js".
* If there is no digest file found then digest values for ETags are formed by forming a sha1 digest of the last-modified
* time.
*
* The default digest algorithm to search for is "md5". You can override this quite easily. For example if the SHA-1
* algorithm is preferred:
*
* {{{
* "assets.digest.algorithm" = "sha1"
* }}}
*
* You can set a custom Cache directive for a particular resource if needed. For example in your application.conf file:
*
* {{{
* "assets.cache./public/images/logo.png" = "max-age=3600"
* }}}
*
* You can use this controller in any application, just by declaring the appropriate route. For example:
* {{{
* GET /assets/\uFEFF*file controllers.Assets.at(path="/public", file)
* }}}
*/
object Assets extends AssetsBuilder {
import AssetInfo._
// Caching. It is unfortunate that we require both a digestCache and an assetInfo cache given that digest info is
// part of asset information. The reason for this is that the assetInfo cache returns a Future[AssetInfo] in order to
// avoid any thundering herds issue. The unbind method of the assetPathBindable doesn't support the return of a
// Future - unbinds are expected to be blocking. Thus we separate out the caching of a digest from the caching of
// full asset information. At least the determination of the digest should be relatively quick (certainly not as
// involved as determining the full asset info).
val digestCache = TrieMap[String, Option[String]]()
private[controllers] def digest(path: String): Option[String] = {
digestCache.getOrElse(path, {
val maybeDigestUrl: Option[URL] = resource(path + "." + digestAlgorithm)
val maybeDigest: Option[String] = maybeDigestUrl.map(Source.fromURL(_).mkString)
if (!isDev && maybeDigest.isDefined) digestCache.put(path, maybeDigest)
maybeDigest
})
}
// Sames goes for the minified paths cache.
val minifiedPathsCache = TrieMap[String, String]()
lazy val checkForMinified = config(_.getBoolean("assets.checkForMinified")).getOrElse(true)
private[controllers] def minifiedPath(path: String): String = {
minifiedPathsCache.getOrElse(path, {
def minifiedPathFor(delim: Char): Option[String] = {
val ext = path.reverse.takeWhile(_ != '.').reverse
val noextPath = path.dropRight(ext.size + 1)
val minPath = noextPath + delim + "min." + ext
resource(minPath).map(_ => minPath)
}
val maybeMinifiedPath = if (checkForMinified) {
minifiedPathFor('.').orElse(minifiedPathFor('-')).getOrElse(path)
} else {
path
}
if (!isDev) minifiedPathsCache.put(path, maybeMinifiedPath)
maybeMinifiedPath
})
}
private[controllers] lazy val assetInfoCache = new SelfPopulatingMap[String, AssetInfo]()
private def assetInfoFromResource(name: String): Option[AssetInfo] = {
blocking {
for {
url <- resource(name)
} yield {
val gzipUrl: Option[URL] = resource(name + ".gz")
new AssetInfo(name, url, gzipUrl, digest(name))
}
}
}
private def assetInfo(name: String): Future[Option[AssetInfo]] = {
if (isDev) {
Future.successful(assetInfoFromResource(name))
} else {
assetInfoCache.putIfAbsent(name)(assetInfoFromResource)(Implicits.trampoline)
}
}
private[controllers] def assetInfoForRequest(request: Request[_], name: String): Future[Option[(AssetInfo, Boolean)]] = {
val gzipRequested = request.headers.get(ACCEPT_ENCODING).exists(_.split(',').exists(_.trim == "gzip"))
assetInfo(name).map(_.map(_ -> gzipRequested))(Implicits.trampoline)
}
/**
* An asset.
*
* @param name The name of the asset.
*/
case class Asset(name: String)
object Asset {
import scala.language.implicitConversions
implicit def string2Asset(name: String) = new Asset(name)
private def pathFromParams(rrc: ReverseRouteContext): String = {
rrc.fixedParams.getOrElse("path",
throw new RuntimeException("Asset path bindable must be used in combination with an action that accepts a path parameter")
).toString
}
implicit def assetPathBindable(implicit rrc: ReverseRouteContext) = new PathBindable[Asset] {
def bind(key: String, value: String) = Right(new Asset(value))
def unbind(key: String, value: Asset): String = {
val base = pathFromParams(rrc)
val path = base + "/" + value.name
blocking {
val minPath = minifiedPath(path)
digest(minPath).fold(minPath) { dgst =>
val lastSep = minPath.lastIndexOf("/")
minPath.take(lastSep + 1) + dgst + "-" + minPath.drop(lastSep + 1)
}.drop(base.size + 1)
}
}
}
}
}
class AssetsBuilder extends Controller {
import Assets._
import AssetInfo._
private def currentTimeFormatted: String = df.print((new Date).getTime)
private def maybeNotModified(request: Request[_], assetInfo: AssetInfo, aggressiveCaching: Boolean): Option[Result] = {
// First check etag. Important, if there is an If-None-Match header, we MUST not check the
// If-Modified-Since header, regardless of whether If-None-Match matches or not. This is in
// accordance with section 14.26 of RFC2616.
request.headers.get(IF_NONE_MATCH) match {
case Some(etags) =>
assetInfo.etag.filter(someEtag => etags.split(',').exists(_.trim == someEtag)).flatMap(_ => Some(cacheableResult(assetInfo, aggressiveCaching, NotModified)))
case None =>
for {
ifModifiedSinceStr <- request.headers.get(IF_MODIFIED_SINCE)
ifModifiedSince <- parseDate(ifModifiedSinceStr)
lastModified <- assetInfo.parsedLastModified
if !lastModified.after(ifModifiedSince)
} yield {
NotModified.withHeaders(DATE -> currentTimeFormatted)
}
}
}
private def cacheableResult[A <: Result](assetInfo: AssetInfo, aggressiveCaching: Boolean, r: A): Result = {
def addHeaderIfValue(name: String, maybeValue: Option[String], response: Result): Result = {
maybeValue.fold(response)(v => response.withHeaders(name -> v))
}
val r1 = addHeaderIfValue(ETAG, assetInfo.etag, r)
val r2 = addHeaderIfValue(LAST_MODIFIED, assetInfo.lastModified, r1)
r2.withHeaders(CACHE_CONTROL -> assetInfo.cacheControl(aggressiveCaching))
}
private def result(file: String,
length: Int,
mimeType: String,
resourceData: Enumerator[Array[Byte]],
gzipRequested: Boolean,
gzipAvailable: Boolean): Result = {
val response = Result(
ResponseHeader(
OK,
Map(
CONTENT_LENGTH -> length.toString,
CONTENT_TYPE -> mimeType,
DATE -> currentTimeFormatted
)
),
resourceData)
if (gzipRequested && gzipAvailable) {
response.withHeaders(VARY -> ACCEPT_ENCODING, CONTENT_ENCODING -> "gzip")
} else if (gzipAvailable) {
response.withHeaders(VARY -> ACCEPT_ENCODING)
} else {
response
}
}
/**
* Generates an `Action` that serves a versioned static resource.
*/
def versioned(path: String, file: Asset): Action[AnyContent] = {
val f = new File(file.name)
// We want to detect if it's a fingerprinted asset, because if it's fingerprinted, we can aggressively cache it,
// otherwise we can't.
val requestedDigest = f.getName.takeWhile(_ != '-')
if (!requestedDigest.isEmpty) {
val bareFile = new File(f.getParent, f.getName.drop(requestedDigest.size + 1)).getPath
val bareFullPath = new File(path + File.separator + bareFile).getPath
blocking(digest(bareFullPath)) match {
case Some(`requestedDigest`) => at(path, bareFile, aggressiveCaching = true)
case _ => at(path, file.name)
}
} else {
at(path, file.name)
}
}
/**
* Generates an `Action` that serves a static resource.
*
* @param path the root folder for searching the static resource files, such as `"/public"`. Not URL encoded.
* @param file the file part extracted from the URL. May be URL encoded (note that %2F decodes to literal /).
* @param aggressiveCaching if true then an aggressive set of caching directives will be used. Defaults to false.
*/
def at(path: String, file: String, aggressiveCaching: Boolean = false): Action[AnyContent] = Action.async {
implicit request =>
import Implicits.trampoline
val assetName: Option[String] = resourceNameAt(path, file)
val assetInfoFuture: Future[Option[(AssetInfo, Boolean)]] = assetName.map { name =>
assetInfoForRequest(request, name)
} getOrElse Future.successful(None)
val pendingResult: Future[Result] = assetInfoFuture.map {
case Some((assetInfo, gzipRequested)) =>
val stream = assetInfo.url(gzipRequested).openStream()
val length = stream.available
val resourceData = Enumerator.fromStream(stream)(Implicits.defaultExecutionContext)
maybeNotModified(request, assetInfo, aggressiveCaching).getOrElse {
cacheableResult(
assetInfo,
aggressiveCaching,
result(file, length, assetInfo.mimeType, resourceData, gzipRequested, assetInfo.gzipUrl.isDefined)
)
}
case None => NotFound
}
pendingResult.recoverWith {
case e: InvalidUriEncodingException =>
Play.maybeApplication.fold(Future.successful(BadRequest: Result)) { app =>
app.global.onBadRequest(request, s"Invalid URI encoding for $file at $path: " + e.getMessage)
}
case NonFatal(e) =>
// Add a bit more information to the exception for better error reporting later
throw new RuntimeException(s"Unexpected error while serving $file at $path: " + e.getMessage, e)
}
}
/**
* Get the name of the resource for a static resource. Used by `at`.
*
* @param path the root folder for searching the static resource files, such as `"/public"`. Not URL encoded.
* @param file the file part extracted from the URL. May be URL encoded (note that %2F decodes to literal /).
*/
private[controllers] def resourceNameAt(path: String, file: String): Option[String] = {
val decodedFile = UriEncoding.decodePath(file, "utf-8")
def dblSlashRemover(input: String): String = dblSlashPattern.replaceAllIn(input, "/")
val resourceName = dblSlashRemover(s"/$path/$decodedFile")
val resourceFile = new File(resourceName)
val pathFile = new File(path)
if (!resourceFile.getCanonicalPath.startsWith(pathFile.getCanonicalPath)) {
None
} else {
Some(resourceName)
}
}
private val dblSlashPattern = """//+""".r
}
Other Play Framework source code examplesHere is a short list of links related to this Play Framework Assets.scala source code file: |
| ... this post is sponsored by my books ... | |
#1 New Release! |
FP Best Seller |
Copyright 1998-2024 Alvin Alexander, alvinalexander.com
All Rights Reserved.
A percentage of advertising revenue from
pages under the /java/jwarehouse
URI on this website is
paid back to open source projects.