mirror of https://github.com/sbt/sbt.git
Refactor HttpServer
This commit is contained in:
parent
4679d5fadf
commit
efa77a1009
|
|
@ -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,188 +77,158 @@ 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)
|
val f = new File(baseDir, path.toList.mkString("/"))
|
||||||
|
f.getParentFile.mkdirs()
|
||||||
|
|
||||||
def write(path: Seq[String], req: Request): Boolean = {
|
var os: FileOutputStream = null
|
||||||
|
var lock: FileLock = null
|
||||||
val f = new File(baseDir, path.toList.mkString("/"))
|
try {
|
||||||
f.getParentFile.mkdirs()
|
os = new FileOutputStream(f)
|
||||||
|
lock =
|
||||||
var os: FileOutputStream = null
|
try os.getChannel.tryLock()
|
||||||
var lock: FileLock = null
|
catch {
|
||||||
try {
|
case _: OverlappingFileLockException =>
|
||||||
os = new FileOutputStream(f)
|
null
|
||||||
lock =
|
|
||||||
try os.getChannel.tryLock()
|
|
||||||
catch {
|
|
||||||
case _: OverlappingFileLockException =>
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lock == null)
|
|
||||||
false
|
|
||||||
else {
|
|
||||||
req.body.runLog.unsafePerformSync.foreach { b =>
|
|
||||||
b.copyToStream(os)
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
if (lock != null)
|
if (lock == null)
|
||||||
lock.release()
|
false
|
||||||
if (os != null)
|
else {
|
||||||
os.close()
|
req.body.runLog.unsafePerformSync.foreach { b =>
|
||||||
|
b.copyToStream(os)
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (lock != null)
|
||||||
|
lock.release()
|
||||||
|
if (os != null)
|
||||||
|
os.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def isDirectory(f: File): Task[Option[Boolean]] =
|
||||||
|
Task {
|
||||||
|
if (f.isDirectory)
|
||||||
|
Some(true)
|
||||||
|
else if (f.isFile)
|
||||||
|
Some(false)
|
||||||
|
else
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
def directoryListingPage(dir: File, title: String): Task[String] =
|
||||||
Console.err.println(
|
Task {
|
||||||
"Warning: authentication enabled but no realm specified. " +
|
val entries = dir
|
||||||
"Specify one with the --realm or -r option."
|
.listFiles()
|
||||||
)
|
.flatMap { f =>
|
||||||
|
def name = f.getName
|
||||||
val unauthorized = Unauthorized(Challenge("Basic", options.realm))
|
if (f.isDirectory)
|
||||||
|
Seq(name + "/")
|
||||||
def authenticated(pf: PartialFunction[Request, Task[Response]]): HttpService =
|
else if (f.isFile)
|
||||||
authenticated0(HttpService(pf))
|
Seq(name)
|
||||||
|
else
|
||||||
def authenticated0(service: HttpService): HttpService =
|
Nil
|
||||||
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 {
|
// meh escaping
|
||||||
|
// TODO Use to scalatags to generate that
|
||||||
|
s"""<!DOCTYPE html>
|
||||||
|
|<html>
|
||||||
|
|<head>
|
||||||
|
|<title>$title</title>
|
||||||
|
|</head>
|
||||||
|
|<body>
|
||||||
|
|<ul>
|
||||||
|
|${entries.map(e => " <li><a href=\"" + e + "\">" + e + "</a></li>").mkString("\n")}
|
||||||
|
|</ul>
|
||||||
|
|</body>
|
||||||
|
|</html>
|
||||||
|
""".stripMargin
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 =>
|
case req @ PUT -> path =>
|
||||||
if (verbosityLevel >= 1)
|
if (verbosityLevel >= 1)
|
||||||
Console.err.println(s"PUT $path")
|
Console.err.println(s"PUT $path")
|
||||||
|
|
||||||
if (write(path.toList, req))
|
if (write(baseDir, path.toList, req))
|
||||||
Ok()
|
Ok()
|
||||||
else
|
else
|
||||||
Locked()
|
Locked()
|
||||||
}
|
}
|
||||||
|
|
||||||
def postService = authenticated {
|
def postService(baseDir: File, auth: AuthOptions, verbosityLevel: Int) =
|
||||||
|
authenticated(auth, verbosityLevel) {
|
||||||
case req @ POST -> path =>
|
case req @ POST -> path =>
|
||||||
if (verbosityLevel >= 1)
|
if (verbosityLevel >= 1)
|
||||||
Console.err.println(s"POST $path")
|
Console.err.println(s"POST $path")
|
||||||
|
|
||||||
if (write(path.toList, req))
|
if (write(baseDir, path.toList, req))
|
||||||
Ok()
|
Ok()
|
||||||
else
|
else
|
||||||
Locked()
|
Locked()
|
||||||
}
|
}
|
||||||
|
|
||||||
def isDirectory(f: File): Task[Option[Boolean]] =
|
def getService(baseDir: File, auth: AuthOptions, verbosityLevel: Int, listPages: Boolean) =
|
||||||
Task {
|
authenticated(auth, verbosityLevel) {
|
||||||
if (f.isDirectory)
|
|
||||||
Some(true)
|
|
||||||
else if (f.isFile)
|
|
||||||
Some(false)
|
|
||||||
else
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
def directoryListingPage(dir: File, title: String): Task[String] =
|
|
||||||
Task {
|
|
||||||
val entries = dir
|
|
||||||
.listFiles()
|
|
||||||
.flatMap { f =>
|
|
||||||
def name = f.getName
|
|
||||||
if (f.isDirectory)
|
|
||||||
Seq(name + "/")
|
|
||||||
else if (f.isFile)
|
|
||||||
Seq(name)
|
|
||||||
else
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// meh escaping
|
|
||||||
// TODO Use to scalatags to generate that
|
|
||||||
s"""<!DOCTYPE html>
|
|
||||||
|<html>
|
|
||||||
|<head>
|
|
||||||
|<title>$title</title>
|
|
||||||
|</head>
|
|
||||||
|<body>
|
|
||||||
|<ul>
|
|
||||||
|${entries.map(e => " <li><a href=\"" + e + "\">" + e + "</a></li>").mkString("\n")}
|
|
||||||
|</ul>
|
|
||||||
|</body>
|
|
||||||
|</html>
|
|
||||||
""".stripMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
def getService = authenticated {
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue