Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add static fileserver #2450

Merged
merged 5 commits into from
Sep 30, 2023
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions zio-http/src/main/scala/zio/http/StaticServe.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package zio.http

import java.io.File

import zio.ZIO

import zio.http.codec.{PathCodec, SegmentCodec}

sealed trait StaticServe[-R, +E, -I, +A] { self =>

def run(path: Path): Handler[R, E, I, A]

def orElse[R1 <: R, E1, I1 <: I, A1 >: A](that: => StaticServe[R1, E1, I1, A1]): StaticServe[R1, E1, I1, A1] =
StaticServe.run { path =>
self.run(path).orElse(that.run(path))
}

}

object StaticServe {

def run[R, E, I, A](f: Path => Handler[R, E, I, A]): StaticServe[R, E, I, A] =
new StaticServe[R, E, I, A] {
override def run(path: Path) = f(path)
}

def fromFileZIO[R](zio: => ZIO[R, Throwable, File]): StaticServe[R, Throwable, Any, Response] = run { _ =>
Handler.fromFileZIO(zio)
}

def fromDirectory(docRoot: File): StaticServe[Any, Throwable, Any, Response] = run { path =>
val target = new File(docRoot.getAbsolutePath() + path.encode)
if (target.getCanonicalPath.startsWith(docRoot.getCanonicalPath)) Handler.fromFile(target)
else {
Handler.fromZIO(
ZIO.logWarning(s"attempt to access file outside of docRoot: ${target.getAbsolutePath}"),
) *> Handler.badRequest
}
}

def fromDirectory(docRoot: String): StaticServe[Any, Throwable, Any, Response] =
fromDirectory(new File(docRoot))

def fromResource: StaticServe[Any, Throwable, Any, Response] = run { path =>
Handler.fromResource(path.dropLeadingSlash.encode)
}

private def middleware[R, E](
mountpoint: RoutePattern[_],
staticServe: StaticServe[R, E, Any, Response],
): Middleware[R] =
new Middleware[R] {

private def checkFishy(acc: Boolean, segment: String): Boolean = {
val stop = segment.indexOf('/') >= 0 || segment.indexOf('\\') >= 0 || segment == ".."
acc || stop
}

override def apply[Env1 <: R, Err](routes: Routes[Env1, Err]): Routes[Env1, Err] = {
val pattern = mountpoint / trailing
val other = Routes(
pattern -> Handler
.identity[Request]
.flatMap { request =>
val isFishy = request.path.segments.foldLeft(false)(checkFishy)
if (isFishy) {
Handler.fromZIO(ZIO.logWarning(s"fishy request detected: ${request.path.encode}")) *> Handler.badRequest
} else {
val segs = pattern.pathCodec.segments.collect { case SegmentCodec.Literal(v, _) =>
v
}
val unnest = segs.foldLeft(Path.empty)(_ / _).addLeadingSlash
val path = request.path.unnest(unnest).addLeadingSlash
staticServe.run(path).sandbox
}
},
)
routes ++ other
}
}

def middleware[R, E](path: Path, staticServe: StaticServe[R, E, Any, Response]): Middleware[R] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be toMiddleware.

Also, I think there is value in only having a public Middleware.staticServer interface, and hiding all these types. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think it's a good idea to stay consistent with Middleware in general.

If I see that correctly we don´t need anything composable for the user when configuring a static server. It could be enough to only support the two main use-cases:

  1. publish a given directory at a specified path: Middleware.staticServer.fromDirectory(atPath:Path, docRoot: File):Middleware

  2. publish resources at a specified path: Middleware.staticServer.fromResources(atPath:Path):Middleware

Is that what you mean? If yes, I think we should do it that way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One drawback would be that we could not add caching of static assets transparently, no (assuming we want to do that)? With StaticServe we could add a new operator cached:StaticServe that can cache results. Maybe it's best to go with 1. and 2. from above and wait for feedback...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go for:

object Middleware {
  ...
  def serveDirectory(path: Path, docRoot: File, cached: Boolean = false): Middleware
}

Then it has minimal impact on the surface area, and all of these helper classes / functions can be made private inside the companion object of Middleware.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discarded adding an option for cache as that should be more carefully thought through - if I find time to do that we can still add that option without any problems. Besides that I changed the API according to your suggestion!

middleware(
Method.GET / path.segments.map(PathCodec.literal).reduceLeft(_ / _),
staticServe,
)

}