Refactor HttpServer

This commit is contained in:
Alexandre Archambault 2017-04-29 17:33:26 +02:00
parent 4679d5fadf
commit efa77a1009
1 changed files with 187 additions and 155 deletions

View File

@ -8,13 +8,59 @@ import org.http4s._
import org.http4s.dsl._ import org.http4s.dsl._
import org.http4s.headers.{Authorization, `Content-Type`} import org.http4s.headers.{Authorization, `Content-Type`}
import org.http4s.server.blaze.BlazeBuilder import org.http4s.server.blaze.BlazeBuilder
import org.http4s.server.Server
import caseapp._ import caseapp._
import scala.collection.JavaConverters._ import scala.collection.JavaConverters._
import scalaz.concurrent.Task import scalaz.concurrent.Task
final case class AuthOptions(
@ExtraName("u")
@ValueDescription("user")
user: String,
@ExtraName("P")
@ValueDescription("password")
password: String,
@ExtraName("r")
@ValueDescription("realm")
realm: String
) {
def checks(): Unit = {
if (user.nonEmpty && password.isEmpty)
Console.err.println(
"Warning: authentication enabled but no password specified. " +
"Specify one with the --password or -P option."
)
if (password.nonEmpty && user.isEmpty)
Console.err.println(
"Warning: authentication enabled but no user specified. " +
"Specify one with the --user or -u option."
)
if ((user.nonEmpty || password.nonEmpty) && realm.isEmpty)
Console.err.println(
"Warning: authentication enabled but no realm specified. " +
"Specify one with the --realm or -r option."
)
}
}
final case class VerbosityOptions(
@ExtraName("v")
verbose: Int @@ Counter,
@ExtraName("q")
quiet: Boolean
) {
lazy val verbosityLevel = Tag.unwrap(verbose) - (if (quiet) 1 else 0)
}
final case class HttpServerOptions( final case class HttpServerOptions(
@Recurse
auth: AuthOptions,
@Recurse
verbosity: VerbosityOptions,
@ExtraName("d") @ExtraName("d")
@ValueDescription("served directory") @ValueDescription("served directory")
directory: String, directory: String,
@ -31,32 +77,14 @@ final case class HttpServerOptions(
@ExtraName("w") @ExtraName("w")
@HelpMessage("Accept write requests. Equivalent to -s -t") @HelpMessage("Accept write requests. Equivalent to -s -t")
acceptWrite: Boolean, acceptWrite: Boolean,
@ExtraName("v")
verbose: Int @@ Counter,
@ExtraName("q")
quiet: Boolean,
@ExtraName("u")
@ValueDescription("user")
user: String,
@ExtraName("P")
@ValueDescription("password")
password: String,
@ExtraName("r")
@ValueDescription("realm")
realm: String,
@ExtraName("l") @ExtraName("l")
@HelpMessage("Generate content listing pages for directories") @HelpMessage("Generate content listing pages for directories")
listPages: Boolean listPages: Boolean
) )
object HttpServer extends CaseApp[HttpServerOptions] { object HttpServer extends CaseApp[HttpServerOptions] {
def run(options: HttpServerOptions, args: RemainingArgs): Unit = {
val baseDir = new File(if (options.directory.isEmpty) "." else options.directory) def write(baseDir: File, path: Seq[String], req: Request): Boolean = {
val verbosityLevel = Tag.unwrap(options.verbose) - (if (options.quiet) 1 else 0)
def write(path: Seq[String], req: Request): Boolean = {
val f = new File(baseDir, path.toList.mkString("/")) val f = new File(baseDir, path.toList.mkString("/"))
f.getParentFile.mkdirs() f.getParentFile.mkdirs()
@ -89,89 +117,6 @@ object HttpServer extends CaseApp[HttpServerOptions] {
} }
} }
if (options.user.nonEmpty && options.password.isEmpty)
Console.err.println(
"Warning: authentication enabled but no password specified. " +
"Specify one with the --password or -P option."
)
if (options.password.nonEmpty && options.user.isEmpty)
Console.err.println(
"Warning: authentication enabled but no user specified. " +
"Specify one with the --user or -u option."
)
if ((options.user.nonEmpty || options.password.nonEmpty) && options.realm.isEmpty)
Console.err.println(
"Warning: authentication enabled but no realm specified. " +
"Specify one with the --realm or -r option."
)
val unauthorized = Unauthorized(Challenge("Basic", options.realm))
def authenticated(pf: PartialFunction[Request, Task[Response]]): HttpService =
authenticated0(HttpService(pf))
def authenticated0(service: HttpService): HttpService =
if (options.user.isEmpty && options.password.isEmpty)
service
else
HttpService {
case req =>
def warn(msg: => String) =
if (verbosityLevel >= 1)
Console.err.println(s"${req.method.name} ${req.uri.path}: $msg")
req.headers.get(Authorization) match {
case None =>
warn("no authentication provided")
unauthorized
case Some(auth) =>
auth.credentials match {
case basic: BasicCredentials =>
if (basic.username == options.user && basic.password == options.password)
service.run(req)
else {
warn {
val msg =
if (basic.username == options.user)
"wrong password"
else
s"unrecognized user ${basic.username}"
s"authentication failed ($msg)"
}
unauthorized
}
case _ =>
warn("no basic credentials found")
unauthorized
}
}
}
def putService = authenticated {
case req @ PUT -> path =>
if (verbosityLevel >= 1)
Console.err.println(s"PUT $path")
if (write(path.toList, req))
Ok()
else
Locked()
}
def postService = authenticated {
case req @ POST -> path =>
if (verbosityLevel >= 1)
Console.err.println(s"POST $path")
if (write(path.toList, req))
Ok()
else
Locked()
}
def isDirectory(f: File): Task[Option[Boolean]] = def isDirectory(f: File): Task[Option[Boolean]] =
Task { Task {
if (f.isDirectory) if (f.isDirectory)
@ -182,6 +127,8 @@ object HttpServer extends CaseApp[HttpServerOptions] {
None None
} }
def directoryListingPage(dir: File, title: String): Task[String] = def directoryListingPage(dir: File, title: String): Task[String] =
Task { Task {
val entries = dir val entries = dir
@ -212,7 +159,76 @@ object HttpServer extends CaseApp[HttpServerOptions] {
""".stripMargin """.stripMargin
} }
def getService = authenticated {
def unauthorized(realm: String) = Unauthorized(Challenge("Basic", realm))
def authenticated0(options: AuthOptions, verbosityLevel: Int)(service: HttpService): HttpService =
if (options.user.isEmpty && options.password.isEmpty)
service
else
HttpService {
case req =>
def warn(msg: => String) =
if (verbosityLevel >= 1)
Console.err.println(s"${req.method.name} ${req.uri.path}: $msg")
req.headers.get(Authorization) match {
case None =>
warn("no authentication provided")
unauthorized(options.realm)
case Some(auth) =>
auth.credentials match {
case basic: BasicCredentials =>
if (basic.username == options.user && basic.password == options.password)
service.run(req)
else {
warn {
val msg =
if (basic.username == options.user)
"wrong password"
else
s"unrecognized user ${basic.username}"
s"authentication failed ($msg)"
}
unauthorized(options.realm)
}
case _ =>
warn("no basic credentials found")
unauthorized(options.realm)
}
}
}
def authenticated(options: AuthOptions, verbosityLevel: Int)(pf: PartialFunction[Request, Task[Response]]): HttpService =
authenticated0(options, verbosityLevel)(HttpService(pf))
def putService(baseDir: File, auth: AuthOptions, verbosityLevel: Int) =
authenticated(auth, verbosityLevel) {
case req @ PUT -> path =>
if (verbosityLevel >= 1)
Console.err.println(s"PUT $path")
if (write(baseDir, path.toList, req))
Ok()
else
Locked()
}
def postService(baseDir: File, auth: AuthOptions, verbosityLevel: Int) =
authenticated(auth, verbosityLevel) {
case req @ POST -> path =>
if (verbosityLevel >= 1)
Console.err.println(s"POST $path")
if (write(baseDir, path.toList, req))
Ok()
else
Locked()
}
def getService(baseDir: File, auth: AuthOptions, verbosityLevel: Int, listPages: Boolean) =
authenticated(auth, verbosityLevel) {
case (method @ (GET | HEAD)) -> path => case (method @ (GET | HEAD)) -> path =>
if (verbosityLevel >= 1) if (verbosityLevel >= 1)
Console.err.println(s"${method.name} $path") Console.err.println(s"${method.name} $path")
@ -223,7 +239,7 @@ object HttpServer extends CaseApp[HttpServerOptions] {
for { for {
isDirOpt <- isDirectory(f) isDirOpt <- isDirectory(f)
resp <- isDirOpt match { resp <- isDirOpt match {
case Some(true) if options.listPages => case Some(true) if listPages =>
directoryListingPage(f, relPath).flatMap(page => directoryListingPage(f, relPath).flatMap(page =>
Ok(page).withContentType(Some(`Content-Type`(MediaType.`text/html`))) Ok(page).withContentType(Some(`Content-Type`(MediaType.`text/html`)))
) )
@ -240,31 +256,47 @@ object HttpServer extends CaseApp[HttpServerOptions] {
} }
} }
def server(options: HttpServerOptions): Task[Server] = {
val baseDir = new File(if (options.directory.isEmpty) "." else options.directory)
options.auth.checks()
val builder = { val builder = {
var b = BlazeBuilder.bindHttp(options.port, options.host) var b = BlazeBuilder.bindHttp(options.port, options.host)
if (options.acceptWrite || options.acceptPut) if (options.acceptWrite || options.acceptPut)
b = b.mountService(putService) b = b.mountService(putService(baseDir, options.auth, options.verbosity.verbosityLevel))
if (options.acceptWrite || options.acceptPost) if (options.acceptWrite || options.acceptPost)
b = b.mountService(postService) b = b.mountService(postService(baseDir, options.auth, options.verbosity.verbosityLevel))
b = b.mountService(getService) b = b.mountService(getService(baseDir, options.auth, options.verbosity.verbosityLevel, options.listPages))
b b
} }
if (verbosityLevel >= 0) { if (options.verbosity.verbosityLevel >= 0) {
Console.err.println(s"Listening on http://${options.host}:${options.port}") Console.err.println(s"Listening on http://${options.host}:${options.port}")
if (verbosityLevel >= 1 && options.host == "0.0.0.0") { if (options.verbosity.verbosityLevel >= 1 && options.host == "0.0.0.0") {
Console.err.println(s"Listening on addresses") Console.err.println(s"Listening on addresses")
for (itf <- NetworkInterface.getNetworkInterfaces.asScala; addr <- itf.getInetAddresses.asScala) for (itf <- NetworkInterface.getNetworkInterfaces.asScala; addr <- itf.getInetAddresses.asScala)
Console.err.println(s" ${addr.getHostAddress} (${itf.getName})") Console.err.println(s" ${addr.getHostAddress} (${itf.getName})")
} }
} }
builder builder.start
.run }
def run(options: HttpServerOptions, args: RemainingArgs): Unit = {
if (args.args.nonEmpty)
Console.err.println(
s"Warning: ignoring extra arguments passed on the command-line (${args.args.mkString(", ")})"
)
server(options)
.unsafePerformSync
.awaitShutdown() .awaitShutdown()
} }