diff --git a/akka-http-core/src/main/scala/akka/http/util/StreamUtils.scala b/akka-http-core/src/main/scala/akka/http/util/StreamUtils.scala
index adb5bf8231..646b7f2ab4 100644
--- a/akka-http-core/src/main/scala/akka/http/util/StreamUtils.scala
+++ b/akka-http-core/src/main/scala/akka/http/util/StreamUtils.scala
@@ -5,18 +5,21 @@
package akka.http.util
import java.util.concurrent.atomic.AtomicBoolean
-import org.reactivestreams.Subscriber
+import java.io.InputStream
-import akka.http.model.RequestEntity
-import akka.stream.impl.ErrorPublisher
-import akka.stream.{ impl, Transformer, FlowMaterializer }
-import akka.stream.scaladsl._
-import akka.util.ByteString
-import org.reactivestreams.Publisher
+import org.reactivestreams.{ Subscriber, Publisher }
import scala.collection.immutable
import scala.concurrent.{ ExecutionContext, Future }
+import akka.actor.Props
+import akka.util.ByteString
+
+import akka.stream.{ impl, Transformer, FlowMaterializer }
+import akka.stream.scaladsl._
+
+import akka.http.model.RequestEntity
+
/**
* INTERNAL API
*/
@@ -51,7 +54,7 @@ private[http] object StreamUtils {
}
def failedPublisher[T](ex: Throwable): Publisher[T] =
- ErrorPublisher(ex).asInstanceOf[Publisher[T]]
+ impl.ErrorPublisher(ex).asInstanceOf[Publisher[T]]
def mapErrorTransformer[T](f: Throwable ⇒ Throwable): Transformer[T, T] =
new Transformer[T, T] {
@@ -125,6 +128,49 @@ private[http] object StreamUtils {
def mapEntityError(f: Throwable ⇒ Throwable): RequestEntity ⇒ RequestEntity =
_.transformDataBytes(() ⇒ mapErrorTransformer(f))
+ /**
+ * Simple blocking Source backed by an InputStream.
+ *
+ * FIXME: should be provided by akka-stream, see #15588
+ */
+ def fromInputStreamSource(inputStream: InputStream, defaultChunkSize: Int = 65536): Source[ByteString] = {
+ import akka.stream.impl._
+
+ def props(materializer: ActorBasedFlowMaterializer): Props = {
+ val iterator = new Iterator[ByteString] {
+ var finished = false
+ def hasNext: Boolean = !finished
+ def next(): ByteString =
+ if (!finished) {
+ val buffer = new Array[Byte](defaultChunkSize)
+ val read = inputStream.read(buffer)
+ if (read < 0) {
+ finished = true
+ inputStream.close()
+ ByteString.empty
+ } else ByteString.fromArray(buffer, 0, read)
+ } else ByteString.empty
+ }
+
+ Props(new IteratorPublisherImpl(iterator, materializer.settings)).withDispatcher(materializer.settings.fileIODispatcher)
+ }
+
+ new AtomicBoolean(false) with SimpleActorFlowSource[ByteString] {
+ override def attach(flowSubscriber: Subscriber[ByteString], materializer: ActorBasedFlowMaterializer, flowName: String): Unit =
+ create(materializer, flowName)._1.subscribe(flowSubscriber)
+
+ override def isActive: Boolean = true
+ override def create(materializer: ActorBasedFlowMaterializer, flowName: String): (Publisher[ByteString], Unit) =
+ if (!getAndSet(true)) {
+ val ref = materializer.actorOf(props(materializer), name = s"$flowName-0-InputStream-source")
+ val publisher = ActorPublisher[ByteString](ref)
+ ref ! ExposedPublisher(publisher.asInstanceOf[impl.ActorPublisher[Any]])
+
+ (publisher, ())
+ } else (ErrorPublisher(new IllegalStateException("One time source can only be instantiated once")).asInstanceOf[Publisher[ByteString]], ())
+ }
+ }
+
/**
* Returns a source that can only be used once for testing purposes.
*/
diff --git a/akka-http-core/src/main/scala/akka/http/util/package.scala b/akka-http-core/src/main/scala/akka/http/util/package.scala
index f2d1c06af6..7fb3787aa2 100644
--- a/akka-http-core/src/main/scala/akka/http/util/package.scala
+++ b/akka-http-core/src/main/scala/akka/http/util/package.scala
@@ -99,5 +99,14 @@ package object util {
private[this] val _identityFunc: Any ⇒ Any = x ⇒ x
/** Returns a constant identity function to avoid allocating the closure */
def identityFunc[T]: T ⇒ T = _identityFunc.asInstanceOf[T ⇒ T]
+
+ def humanReadableByteCount(bytes: Long, si: Boolean): String = {
+ val unit = if (si) 1000 else 1024
+ if (bytes >= unit) {
+ val exp = (math.log(bytes) / math.log(unit)).toInt
+ val pre = if (si) "kMGTPE".charAt(exp - 1).toString else "KMGTPE".charAt(exp - 1).toString + 'i'
+ "%.1f %sB" format (bytes / math.pow(unit, exp), pre)
+ } else bytes.toString + " B"
+ }
}
diff --git a/akka-http-tests/src/test/resources/sample.html b/akka-http-tests/src/test/resources/sample.html
new file mode 100644
index 0000000000..10dbdec8c5
--- /dev/null
+++ b/akka-http-tests/src/test/resources/sample.html
@@ -0,0 +1 @@
+
Lorem ipsum!
\ No newline at end of file
diff --git a/akka-http-tests/src/test/resources/sample.xyz b/akka-http-tests/src/test/resources/sample.xyz
new file mode 100644
index 0000000000..ce42064770
--- /dev/null
+++ b/akka-http-tests/src/test/resources/sample.xyz
@@ -0,0 +1 @@
+XyZ
\ No newline at end of file
diff --git a/akka-http-tests/src/test/resources/someDir/fileA.txt b/akka-http-tests/src/test/resources/someDir/fileA.txt
new file mode 100644
index 0000000000..d800886d9c
--- /dev/null
+++ b/akka-http-tests/src/test/resources/someDir/fileA.txt
@@ -0,0 +1 @@
+123
\ No newline at end of file
diff --git a/akka-http-tests/src/test/resources/someDir/fileB.xml b/akka-http-tests/src/test/resources/someDir/fileB.xml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/akka-http-tests/src/test/resources/someDir/sub/file.html b/akka-http-tests/src/test/resources/someDir/sub/file.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/akka-http-tests/src/test/resources/subDirectory/empty.pdf b/akka-http-tests/src/test/resources/subDirectory/empty.pdf
new file mode 100644
index 0000000000..d800886d9c
--- /dev/null
+++ b/akka-http-tests/src/test/resources/subDirectory/empty.pdf
@@ -0,0 +1 @@
+123
\ No newline at end of file
diff --git a/akka-http-tests/src/test/resources/subDirectory/fileA.txt b/akka-http-tests/src/test/resources/subDirectory/fileA.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/akka-http-tests/src/test/scala/akka/http/server/directives/FileAndResourceDirectivesSpec.scala b/akka-http-tests/src/test/scala/akka/http/server/directives/FileAndResourceDirectivesSpec.scala
new file mode 100644
index 0000000000..0a96fc4de6
--- /dev/null
+++ b/akka-http-tests/src/test/scala/akka/http/server/directives/FileAndResourceDirectivesSpec.scala
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2009-2014 Typesafe Inc.
+ */
+
+package akka.http.server
+package directives
+
+import java.io.{ File, FileOutputStream }
+
+import akka.http.model.MediaTypes._
+import akka.http.model._
+import akka.http.model.headers._
+import akka.http.util._
+import org.scalatest.matchers.Matcher
+import org.scalatest.{ Inside, Inspectors }
+
+import scala.concurrent.duration._
+import scala.concurrent.{ Await, ExecutionContext, Future }
+import scala.util.Properties
+
+class FileAndResourceDirectivesSpec extends RoutingSpec with Inspectors with Inside {
+
+ override def testConfigSource =
+ """akka.http.routing {
+ | file-chunking-threshold-size = 16
+ | file-chunking-chunk-size = 8
+ | range-coalescing-threshold = 1
+ |}""".stripMargin
+
+ "getFromFile" should {
+ "reject non-GET requests" in {
+ Put() ~> getFromFile("some") ~> check { handled shouldEqual (false) }
+ }
+ "reject requests to non-existing files" in {
+ Get() ~> getFromFile("nonExistentFile") ~> check { handled shouldEqual (false) }
+ }
+ "reject requests to directories" in {
+ Get() ~> getFromFile(Properties.javaHome) ~> check { handled shouldEqual (false) }
+ }
+ "return the file content with the MediaType matching the file extension" in {
+ val file = File.createTempFile("akkaHttpTest", ".PDF")
+ try {
+ writeAllText("This is PDF", file)
+ Get() ~> getFromFile(file.getPath) ~> check {
+ mediaType shouldEqual `application/pdf`
+ definedCharset shouldEqual None
+ responseAs[String] shouldEqual "This is PDF"
+ headers should contain(`Last-Modified`(DateTime(file.lastModified)))
+ }
+ } finally file.delete
+ }
+ "return the file content with MediaType 'application/octet-stream' on unknown file extensions" in {
+ val file = File.createTempFile("akkaHttpTest", null)
+ try {
+ writeAllText("Some content", file)
+ Get() ~> getFromFile(file) ~> check {
+ mediaType shouldEqual `application/octet-stream`
+ responseAs[String] shouldEqual "Some content"
+ }
+ } finally file.delete
+ }
+
+ "return a single range from a file" in {
+ val file = File.createTempFile("partialTest", null)
+ try {
+ writeAllText("ABCDEFGHIJKLMNOPQRSTUVWXYZ", file)
+ Get() ~> addHeader(Range(ByteRange(0, 10))) ~> getFromFile(file) ~> check {
+ status shouldEqual StatusCodes.PartialContent
+ headers should contain(`Content-Range`(ContentRange(0, 10, 26)))
+ responseAs[String] shouldEqual "ABCDEFGHIJK"
+ }
+ } finally file.delete
+ }
+
+ "return multiple ranges from a file at once" in {
+ val file = File.createTempFile("partialTest", null)
+ try {
+ writeAllText("ABCDEFGHIJKLMNOPQRSTUVWXYZ", file)
+ val rangeHeader = Range(ByteRange(1, 10), ByteRange.suffix(10))
+ Get() ~> addHeader(rangeHeader) ~> getFromFile(file, ContentTypes.`text/plain`) ~> check {
+ status shouldEqual StatusCodes.PartialContent
+ header[`Content-Range`] shouldEqual None
+ mediaType.withParams(Map.empty) shouldEqual `multipart/byteranges`
+
+ val parts = responseAs[Multipart.ByteRanges].toStrict(100.millis).awaitResult(100.millis).strictParts
+ parts.size shouldEqual 2
+ parts(0).entity.data.utf8String shouldEqual "BCDEFGHIJK"
+ parts(1).entity.data.utf8String shouldEqual "QRSTUVWXYZ"
+ }
+ } finally file.delete
+ }
+ }
+
+ "getFromResource" should {
+ "reject non-GET requests" in {
+ Put() ~> getFromResource("some") ~> check { handled shouldEqual (false) }
+ }
+ "reject requests to non-existing resources" in {
+ Get() ~> getFromResource("nonExistingResource") ~> check { handled shouldEqual (false) }
+ }
+ "return the resource content with the MediaType matching the file extension" in {
+ val route = getFromResource("sample.html")
+
+ def runCheck() =
+ Get() ~> route ~> check {
+ mediaType shouldEqual `text/html`
+ forAtLeast(1, headers) { h ⇒
+ inside(h) {
+ case `Last-Modified`(dt) ⇒
+ DateTime(2011, 7, 1) should be < dt
+ dt.clicks should be < System.currentTimeMillis()
+ }
+ }
+ responseAs[String] shouldEqual "Lorem ipsum!
"
+ }
+
+ runCheck()
+ runCheck() // additional test to check that no internal state is kept
+ }
+ "return the file content with MediaType 'application/octet-stream' on unknown file extensions" in {
+ Get() ~> getFromResource("sample.xyz") ~> check {
+ mediaType shouldEqual `application/octet-stream`
+ responseAs[String] shouldEqual "XyZ"
+ }
+ }
+ }
+
+ "getFromResourceDirectory" should {
+ "reject requests to non-existing resources" in {
+ Get("not/found") ~> getFromResourceDirectory("subDirectory") ~> check { handled shouldEqual (false) }
+ }
+ val verify = check {
+ mediaType shouldEqual `application/pdf`
+ responseAs[String] shouldEqual "123"
+ }
+ "return the resource content with the MediaType matching the file extension - example 1" in { Get("empty.pdf") ~> getFromResourceDirectory("subDirectory") ~> verify }
+ "return the resource content with the MediaType matching the file extension - example 2" in { Get("empty.pdf") ~> getFromResourceDirectory("subDirectory/") ~> verify }
+ "return the resource content with the MediaType matching the file extension - example 3" in { Get("subDirectory/empty.pdf") ~> getFromResourceDirectory("") ~> verify }
+ "reject requests to directory resources" in {
+ Get() ~> getFromResourceDirectory("subDirectory") ~> check { handled shouldEqual (false) }
+ }
+ }
+
+ "listDirectoryContents" should {
+ val base = new File(getClass.getClassLoader.getResource("").toURI).getPath
+ new File(base, "subDirectory/emptySub").mkdir()
+ def eraseDateTime(s: String) = s.replaceAll("""\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d""", "xxxx-xx-xx xx:xx:xx")
+ implicit val settings = RoutingSettings.default.copy(renderVanityFooter = false)
+
+ "properly render a simple directory" in {
+ Get() ~> listDirectoryContents(base + "/someDir") ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /
+ |
+ |Index of /
+ |
+ |
+ |sub/ xxxx-xx-xx xx:xx:xx
+ |fileA.txt xxxx-xx-xx xx:xx:xx 3 B
+ |fileB.xml xxxx-xx-xx xx:xx:xx 0 B
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render a sub directory" in {
+ Get("/sub/") ~> listDirectoryContents(base + "/someDir") ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /sub/
+ |
+ |Index of /sub/
+ |
+ |
+ |../
+ |file.html xxxx-xx-xx xx:xx:xx 0 B
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render the union of several directories" in {
+ Get() ~> listDirectoryContents(base + "/someDir", base + "/subDirectory") ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /
+ |
+ |Index of /
+ |
+ |
+ |emptySub/ xxxx-xx-xx xx:xx:xx
+ |sub/ xxxx-xx-xx xx:xx:xx
+ |empty.pdf xxxx-xx-xx xx:xx:xx 3 B
+ |fileA.txt xxxx-xx-xx xx:xx:xx 3 B
+ |fileB.xml xxxx-xx-xx xx:xx:xx 0 B
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render an empty sub directory with vanity footer" in {
+ val settings = 0 // shadow implicit
+ Get("/emptySub/") ~> listDirectoryContents(base + "/subDirectory") ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /emptySub/
+ |
+ |Index of /emptySub/
+ |
+ |
+ |../
+ |
+ |
+ |
+ |
rendered by Akka Http on xxxx-xx-xx xx:xx:xx
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render an empty top-level directory" in {
+ Get() ~> listDirectoryContents(base + "/subDirectory/emptySub") ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /
+ |
+ |Index of /
+ |
+ |
+ |(no files)
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render a simple directory with a path prefix" in {
+ Get("/files/") ~> pathPrefix("files")(listDirectoryContents(base + "/someDir")) ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /files/
+ |
+ |Index of /files/
+ |
+ |
+ |sub/ xxxx-xx-xx xx:xx:xx
+ |fileA.txt xxxx-xx-xx xx:xx:xx 3 B
+ |fileB.xml xxxx-xx-xx xx:xx:xx 0 B
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render a sub directory with a path prefix" in {
+ Get("/files/sub/") ~> pathPrefix("files")(listDirectoryContents(base + "/someDir")) ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /files/sub/
+ |
+ |Index of /files/sub/
+ |
+ |
+ |../
+ |file.html xxxx-xx-xx xx:xx:xx 0 B
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "properly render an empty top-level directory with a path prefix" in {
+ Get("/files/") ~> pathPrefix("files")(listDirectoryContents(base + "/subDirectory/emptySub")) ~> check {
+ eraseDateTime(responseAs[String]) shouldEqual prep {
+ """
+ |Index of /files/
+ |
+ |Index of /files/
+ |
+ |
+ |(no files)
+ |
+ |
+ |
+ |
+ |"""
+ }
+ }
+ }
+ "reject requests to file resources" in {
+ Get() ~> listDirectoryContents(base + "subDirectory/empty.pdf") ~> check { handled shouldEqual (false) }
+ }
+ }
+
+ def prep(s: String) = s.stripMarginWithNewline("\n")
+
+ def writeAllText(text: String, file: File): Unit = {
+ val fos = new FileOutputStream(file)
+ try {
+ fos.write(text.getBytes("UTF-8"))
+ } finally fos.close()
+ }
+
+ def evaluateTo[T](t: T, atMost: Duration = 100.millis)(implicit ec: ExecutionContext): Matcher[Future[T]] =
+ be(t).compose[Future[T]] { fut ⇒
+ import scala.concurrent.Await
+ fut.awaitResult(atMost)
+ }
+}
diff --git a/akka-http/src/main/resources/reference.conf b/akka-http/src/main/resources/reference.conf
index 927aa7a5c9..7c8a4d5f36 100644
--- a/akka-http/src/main/resources/reference.conf
+++ b/akka-http/src/main/resources/reference.conf
@@ -13,6 +13,12 @@ akka.http.routing {
# (Note that akka-http will always produce log messages containing the full error details)
verbose-error-messages = off
+ # Enables/disables ETag and `If-Modified-Since` support for FileAndResourceDirectives
+ file-get-conditional = on
+
+ # Enables/disables the rendering of the "rendered by" footer in directory listings
+ render-vanity-footer = yes
+
# The maximum size between two requested ranges. Ranges with less space in between will be coalesced.
#
# When multiple ranges are requested, a server may coalesce any of the ranges that overlap or that are separated
diff --git a/akka-http/src/main/scala/akka/http/server/Directives.scala b/akka-http/src/main/scala/akka/http/server/Directives.scala
index a025af73ab..9306135e1d 100644
--- a/akka-http/src/main/scala/akka/http/server/Directives.scala
+++ b/akka-http/src/main/scala/akka/http/server/Directives.scala
@@ -17,7 +17,7 @@ trait Directives extends RouteConcatenation
with DebuggingDirectives
with CodingDirectives
with ExecutionDirectives
- //with FileAndResourceDirectives
+ with FileAndResourceDirectives
//with FormFieldDirectives
with FutureDirectives
with HeaderDirectives
diff --git a/akka-http/src/main/scala/akka/http/server/RoutingSettings.scala b/akka-http/src/main/scala/akka/http/server/RoutingSettings.scala
index e65a2baea0..bf500898ff 100644
--- a/akka-http/src/main/scala/akka/http/server/RoutingSettings.scala
+++ b/akka-http/src/main/scala/akka/http/server/RoutingSettings.scala
@@ -10,12 +10,16 @@ import akka.http.util._
case class RoutingSettings(
verboseErrorMessages: Boolean,
+ fileGetConditional: Boolean,
+ renderVanityFooter: Boolean,
rangeCountLimit: Int,
rangeCoalescingThreshold: Long)
object RoutingSettings extends SettingsCompanion[RoutingSettings]("akka.http.routing") {
def fromSubConfig(c: Config) = apply(
c getBoolean "verbose-error-messages",
+ c getBoolean "file-get-conditional",
+ c getBoolean "render-vanity-footer",
c getInt "range-count-limit",
c getBytes "range-coalescing-threshold")
diff --git a/akka-http/src/main/scala/akka/http/server/directives/FileAndResourceDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/FileAndResourceDirectives.scala
new file mode 100644
index 0000000000..b36d742949
--- /dev/null
+++ b/akka-http/src/main/scala/akka/http/server/directives/FileAndResourceDirectives.scala
@@ -0,0 +1,311 @@
+/*
+ * Copyright (C) 2009-2014 Typesafe Inc.
+ */
+
+package akka.http.server
+package directives
+
+import java.io.{ File, FileInputStream }
+
+import akka.actor.ActorSystem
+import akka.event.LoggingAdapter
+import akka.http.marshalling.{ Marshaller, ToEntityMarshaller }
+import akka.http.model._
+import akka.http.model.headers._
+import akka.http.util._
+
+import scala.annotation.tailrec
+import scala.concurrent.ExecutionContext
+
+trait FileAndResourceDirectives {
+
+ import CacheConditionDirectives._
+ import MethodDirectives._
+ import FileAndResourceDirectives._
+ import RouteDirectives._
+ import BasicDirectives._
+ import RouteConcatenation._
+ import RangeDirectives._
+
+ /**
+ * Completes GET requests with the content of the given file. The actual I/O operation is
+ * running detached in a `Future`, so it doesn't block the current thread (but potentially
+ * some other thread !). If the file cannot be found or read the request is rejected.
+ */
+ def getFromFile(fileName: String)(implicit resolver: ContentTypeResolver): Route =
+ getFromFile(new File(fileName))
+
+ /**
+ * Completes GET requests with the content of the given file. The actual I/O operation is
+ * running detached in a `Future`, so it doesn't block the current thread (but potentially
+ * some other thread !). If the file cannot be found or read the request is rejected.
+ */
+ def getFromFile(file: File)(implicit resolver: ContentTypeResolver): Route =
+ getFromFile(file, resolver(file.getName))
+
+ /**
+ * Completes GET requests with the content of the given file. The actual I/O operation is
+ * running detached in a `Future`, so it doesn't block the current thread (but potentially
+ * some other thread !). If the file cannot be found or read the request is rejected.
+ */
+ def getFromFile(file: File, contentType: ContentType): Route =
+ get {
+ if (file.isFile && file.canRead)
+ conditionalFor(file.length, file.lastModified).apply {
+ withRangeSupport {
+ extractExecutionContext { implicit ec ⇒
+ complete(HttpEntity.Default(contentType, file.length, StreamUtils.fromInputStreamSource(new FileInputStream(file))))
+ }
+ }
+ }
+ else reject
+ }
+
+ private def conditionalFor(length: Long, lastModified: Long): Directive0 =
+ extractSettings.flatMap(settings ⇒
+ if (settings.fileGetConditional) {
+ val tag = java.lang.Long.toHexString(lastModified ^ java.lang.Long.reverse(length))
+ val lastModifiedDateTime = DateTime(math.min(lastModified, System.currentTimeMillis))
+ conditional(EntityTag(tag), lastModifiedDateTime)
+ } else pass)
+
+ /**
+ * Completes GET requests with the content of the given resource. The actual I/O operation is
+ * running detached in a `Future`, so it doesn't block the current thread (but potentially
+ * some other thread !).
+ * If the resource cannot be found or read the Route rejects the request.
+ */
+ def getFromResource(resourceName: String)(implicit resolver: ContentTypeResolver): Route =
+ getFromResource(resourceName, resolver(resourceName))
+
+ /**
+ * Completes GET requests with the content of the given resource. The actual I/O operation is
+ * running detached in a `Future`, so it doesn't block the current thread (but potentially
+ * some other thread !).
+ * If the resource cannot be found or read the Route rejects the request.
+ */
+ def getFromResource(resourceName: String, contentType: ContentType, theClassLoader: ClassLoader = classOf[ActorSystem].getClassLoader): Route =
+ if (!resourceName.endsWith("/"))
+ get {
+ theClassLoader.getResource(resourceName) match {
+ case null ⇒ reject
+ case url ⇒
+ val (length, lastModified) = {
+ val conn = url.openConnection()
+ try {
+ conn.setUseCaches(false) // otherwise the JDK will keep the JAR file open when we close!
+ val len = conn.getContentLength
+ val lm = conn.getLastModified
+ len -> lm
+ } finally conn.getInputStream.close()
+ }
+ conditionalFor(length, lastModified).apply {
+ withRangeSupport {
+ extractExecutionContext { implicit ec ⇒
+ complete {
+ HttpEntity.Default(contentType, length, StreamUtils.fromInputStreamSource(url.openStream()))
+ }
+ }
+ }
+ }
+ }
+ }
+ else reject // don't serve the content of resource "directories"
+
+ /**
+ * Completes GET requests with the content of a file underneath the given directory.
+ * If the file cannot be read the Route rejects the request.
+ */
+ def getFromDirectory(directoryName: String)(implicit resolver: ContentTypeResolver): Route = {
+ val base = withTrailingSlash(directoryName)
+ extractUnmatchedPath { path ⇒
+ extractLog { log ⇒
+ fileSystemPath(base, path, log) match {
+ case "" ⇒ reject
+ case fileName ⇒ getFromFile(fileName)
+ }
+ }
+ }
+ }
+
+ /**
+ * Completes GET requests with a unified listing of the contents of all given directories.
+ * The actual rendering of the directory contents is performed by the in-scope `Marshaller[DirectoryListing]`.
+ */
+ def listDirectoryContents(directories: String*)(implicit renderer: DirectoryRenderer): Route =
+ get {
+ extractRequestContext { ctx ⇒
+ val path = ctx.unmatchedPath
+ val fullPath = ctx.request.uri.path.toString
+ val matchedLength = fullPath.lastIndexOf(path.toString)
+ require(matchedLength >= 0)
+ val pathPrefix = fullPath.substring(0, matchedLength)
+ val pathString = withTrailingSlash(fileSystemPath("/", path, ctx.log, '/'))
+ val dirs = directories flatMap { dir ⇒
+ fileSystemPath(withTrailingSlash(dir), path, ctx.log) match {
+ case "" ⇒ None
+ case fileName ⇒
+ val file = new File(fileName)
+ if (file.isDirectory && file.canRead) Some(file) else None
+ }
+ }
+ import ctx.executionContext
+ implicit val marshaller: ToEntityMarshaller[DirectoryListing] = renderer.marshaller(ctx.settings.renderVanityFooter)
+
+ if (dirs.isEmpty) reject
+ else complete(DirectoryListing(pathPrefix + pathString, isRoot = pathString == "/", dirs.flatMap(_.listFiles)))
+ }
+ }
+
+ /**
+ * Same as `getFromBrowseableDirectories` with only one directory.
+ */
+ def getFromBrowseableDirectory(directory: String)(implicit renderer: DirectoryRenderer, resolver: ContentTypeResolver): Route =
+ getFromBrowseableDirectories(directory)
+
+ /**
+ * Serves the content of the given directories as a file system browser, i.e. files are sent and directories
+ * served as browseable listings.
+ */
+ def getFromBrowseableDirectories(directories: String*)(implicit renderer: DirectoryRenderer, resolver: ContentTypeResolver): Route = {
+ directories.map(getFromDirectory).reduceLeft(_ ~ _) ~ listDirectoryContents(directories: _*)
+ }
+
+ /**
+ * Same as "getFromDirectory" except that the file is not fetched from the file system but rather from a
+ * "resource directory".
+ */
+ def getFromResourceDirectory(directoryName: String)(implicit resolver: ContentTypeResolver): Route = {
+ val base = if (directoryName.isEmpty) "" else withTrailingSlash(directoryName)
+
+ extractUnmatchedPath { path ⇒
+ extractLog { log ⇒
+ fileSystemPath(base, path, log, separator = '/') match {
+ case "" ⇒ reject
+ case resourceName ⇒ getFromResource(resourceName)
+ }
+ }
+ }
+ }
+}
+
+object FileAndResourceDirectives extends FileAndResourceDirectives {
+ private def withTrailingSlash(path: String): String = if (path endsWith "/") path else path + '/'
+ private def fileSystemPath(base: String, path: Uri.Path, log: LoggingAdapter, separator: Char = File.separatorChar): String = {
+ import java.lang.StringBuilder
+ @tailrec def rec(p: Uri.Path, result: StringBuilder = new StringBuilder(base)): String =
+ p match {
+ case Uri.Path.Empty ⇒ result.toString
+ case Uri.Path.Slash(tail) ⇒ rec(tail, result.append(separator))
+ case Uri.Path.Segment(head, tail) ⇒
+ if (head.indexOf('/') >= 0 || head == "..") {
+ log.warning("File-system path for base [{}] and Uri.Path [{}] contains suspicious path segment [{}], " +
+ "GET access was disallowed", base, path, head)
+ ""
+ } else rec(tail, result.append(head))
+ }
+ rec(if (path.startsWithSlash) path.tail else path)
+ }
+
+ trait DirectoryRenderer {
+ def marshaller(renderVanityFooter: Boolean): ToEntityMarshaller[DirectoryListing]
+ }
+ trait LowLevelDirectoryRenderer {
+ implicit def defaultDirectoryRenderer(implicit ec: ExecutionContext): DirectoryRenderer =
+ new DirectoryRenderer {
+ def marshaller(renderVanityFooter: Boolean): ToEntityMarshaller[DirectoryListing] =
+ DirectoryListing.directoryMarshaller(renderVanityFooter)
+ }
+ }
+ object DirectoryRenderer extends LowLevelDirectoryRenderer {
+ implicit def liftMarshaller(implicit _marshaller: ToEntityMarshaller[DirectoryListing]): DirectoryRenderer =
+ new DirectoryRenderer {
+ def marshaller(renderVanityFooter: Boolean): ToEntityMarshaller[DirectoryListing] = _marshaller
+ }
+ }
+}
+
+trait ContentTypeResolver {
+ def apply(fileName: String): ContentType
+}
+
+object ContentTypeResolver {
+
+ /**
+ * The default way of resolving a filename to a ContentType is by looking up the file extension in the
+ * registry of all defined media-types. By default all non-binary file content is assumed to be UTF-8 encoded.
+ */
+ implicit val Default = withDefaultCharset(HttpCharsets.`UTF-8`)
+
+ def withDefaultCharset(charset: HttpCharset): ContentTypeResolver =
+ new ContentTypeResolver {
+ def apply(fileName: String) = {
+ val ext = fileName.lastIndexOf('.') match {
+ case -1 ⇒ ""
+ case x ⇒ fileName.substring(x + 1)
+ }
+ val mediaType = MediaTypes.forExtension(ext) getOrElse MediaTypes.`application/octet-stream`
+ mediaType match {
+ case x if !x.binary ⇒ ContentType(x, charset)
+ case x ⇒ ContentType(x)
+ }
+ }
+ }
+}
+
+case class DirectoryListing(path: String, isRoot: Boolean, files: Seq[File])
+
+object DirectoryListing {
+
+ private val html =
+ """
+ |Index of $
+ |
+ |Index of $
+ |
+ |
+ |$
+ |
$
+ |$
+ |
+ |
+ |""".stripMarginWithNewline("\n") split '$'
+
+ def directoryMarshaller(renderVanityFooter: Boolean)(implicit ec: ExecutionContext): ToEntityMarshaller[DirectoryListing] =
+ Marshaller.StringMarshaller.wrap(MediaTypes.`text/html`) { listing ⇒
+ val DirectoryListing(path, isRoot, files) = listing
+ val filesAndNames = files.map(file ⇒ file -> file.getName).sortBy(_._2)
+ val deduped = filesAndNames.zipWithIndex.flatMap {
+ case (fan @ (file, name), ix) ⇒
+ if (ix == 0 || filesAndNames(ix - 1)._2 != name) Some(fan) else None
+ }
+ val (directoryFilesAndNames, fileFilesAndNames) = deduped.partition(_._1.isDirectory)
+ def maxNameLength(seq: Seq[(File, String)]) = if (seq.isEmpty) 0 else seq.map(_._2.length).max
+ val maxNameLen = math.max(maxNameLength(directoryFilesAndNames) + 1, maxNameLength(fileFilesAndNames))
+ val sb = new java.lang.StringBuilder
+ sb.append(html(0)).append(path).append(html(1)).append(path).append(html(2))
+ if (!isRoot) {
+ val secondToLastSlash = path.lastIndexOf('/', path.lastIndexOf('/', path.length - 1) - 1)
+ sb.append("../\n" format path.substring(0, secondToLastSlash))
+ }
+ def lastModified(file: File) = DateTime(file.lastModified).toIsoLikeDateTimeString
+ def start(name: String) =
+ sb.append("").append(name).append("")
+ .append(" " * (maxNameLen - name.length))
+ def renderDirectory(file: File, name: String) =
+ start(name + '/').append(" ").append(lastModified(file)).append('\n')
+ def renderFile(file: File, name: String) = {
+ val size = akka.http.util.humanReadableByteCount(file.length, si = true)
+ start(name).append(" ").append(lastModified(file))
+ sb.append(" ".substring(size.length)).append(size).append('\n')
+ }
+ for ((file, name) ← directoryFilesAndNames) renderDirectory(file, name)
+ for ((file, name) ← fileFilesAndNames) renderFile(file, name)
+ if (isRoot && files.isEmpty) sb.append("(no files)\n")
+ sb.append(html(3))
+ if (renderVanityFooter) sb.append(html(4)).append(DateTime.now.toIsoLikeDateTimeString).append(html(5))
+ sb.append(html(6)).toString
+ }
+}