diff --git a/http-server/src/main/scala/coursier/HttpServer.scala b/http-server/src/main/scala/coursier/HttpServer.scala index fbef7f8a9..1090c7ee8 100644 --- a/http-server/src/main/scala/coursier/HttpServer.scala +++ b/http-server/src/main/scala/coursier/HttpServer.scala @@ -8,13 +8,59 @@ import org.http4s._ import org.http4s.dsl._ import org.http4s.headers.{Authorization, `Content-Type`} import org.http4s.server.blaze.BlazeBuilder +import org.http4s.server.Server import caseapp._ import scala.collection.JavaConverters._ 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( + @Recurse + auth: AuthOptions, + @Recurse + verbosity: VerbosityOptions, @ExtraName("d") @ValueDescription("served directory") directory: String, @@ -31,188 +77,158 @@ final case class HttpServerOptions( @ExtraName("w") @HelpMessage("Accept write requests. Equivalent to -s -t") 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") @HelpMessage("Generate content listing pages for directories") listPages: Boolean ) 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 = { - - val f = new File(baseDir, path.toList.mkString("/")) - f.getParentFile.mkdirs() - - var os: FileOutputStream = null - var lock: FileLock = null - try { - os = new FileOutputStream(f) - lock = - try os.getChannel.tryLock() - catch { - case _: OverlappingFileLockException => - null - } - - if (lock == null) - false - else { - req.body.runLog.unsafePerformSync.foreach { b => - b.copyToStream(os) - } - - true + var os: FileOutputStream = null + var lock: FileLock = null + try { + os = new FileOutputStream(f) + lock = + try os.getChannel.tryLock() + catch { + case _: OverlappingFileLockException => + null } - } finally { - if (lock != null) - lock.release() - if (os != null) - os.close() + + if (lock == null) + false + else { + 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) - 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 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 } - def putService = authenticated { + // meh escaping + // TODO Use to scalatags to generate that + s""" + | + | + |$title + | + | + | + | + | + """.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 => if (verbosityLevel >= 1) Console.err.println(s"PUT $path") - if (write(path.toList, req)) + if (write(baseDir, path.toList, req)) Ok() else Locked() } - def postService = authenticated { + 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(path.toList, req)) + if (write(baseDir, path.toList, req)) Ok() else Locked() } - def isDirectory(f: File): Task[Option[Boolean]] = - Task { - 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""" - | - | - |$title - | - | - | - | - | - """.stripMargin - } - - def getService = authenticated { + def getService(baseDir: File, auth: AuthOptions, verbosityLevel: Int, listPages: Boolean) = + authenticated(auth, verbosityLevel) { case (method @ (GET | HEAD)) -> path => if (verbosityLevel >= 1) Console.err.println(s"${method.name} $path") @@ -223,7 +239,7 @@ object HttpServer extends CaseApp[HttpServerOptions] { for { isDirOpt <- isDirectory(f) resp <- isDirOpt match { - case Some(true) if options.listPages => + case Some(true) if listPages => directoryListingPage(f, relPath).flatMap(page => 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 = { var b = BlazeBuilder.bindHttp(options.port, options.host) if (options.acceptWrite || options.acceptPut) - b = b.mountService(putService) + b = b.mountService(putService(baseDir, options.auth, options.verbosity.verbosityLevel)) 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 } - if (verbosityLevel >= 0) { + if (options.verbosity.verbosityLevel >= 0) { 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") for (itf <- NetworkInterface.getNetworkInterfaces.asScala; addr <- itf.getInetAddresses.asScala) Console.err.println(s" ${addr.getHostAddress} (${itf.getName})") } } - builder - .run + builder.start + } + + 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() }