From 60d4dce0193fb9d592f2fe065908bfc88da89dff Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sun, 16 Jan 2022 00:22:24 +0000 Subject: Refactor Gemini protocol --- server/src/net/usebox/gemini/server/Server.scala | 193 +++---------------- server/src/net/usebox/gemini/server/URIUtils.scala | 21 +++ .../gemini/server/handlers/GeminiHandler.scala | 129 +++++++++++++ .../gemini/server/handlers/ProtocolHandler.scala | 38 ++++ server/test/src/ServerSpec.scala | 209 --------------------- server/test/src/TestData.scala | 108 +++++++++++ server/test/src/URIUtilsSpec.scala | 26 +++ server/test/src/handlers/ProtocolHandlerSpec.scala | 100 ++++++++++ 8 files changed, 444 insertions(+), 380 deletions(-) create mode 100644 server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala create mode 100644 server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala create mode 100644 server/test/src/TestData.scala create mode 100644 server/test/src/URIUtilsSpec.scala create mode 100644 server/test/src/handlers/ProtocolHandlerSpec.scala diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala index 42a097c..d474bd3 100644 --- a/server/src/net/usebox/gemini/server/Server.scala +++ b/server/src/net/usebox/gemini/server/Server.scala @@ -3,7 +3,6 @@ package net.usebox.gemini.server import java.nio.charset.Charset import javax.net.ssl.SSLEngine import java.net.URI -import java.nio.file.{Path, FileSystems, Files} import scala.util.{Try, Success => TrySuccess} @@ -14,7 +13,7 @@ import akka.stream.scaladsl._ import akka.actor.ActorSystem import akka.util.ByteString -import URIUtils._ +import net.usebox.gemini.server.handlers.GeminiHandler case class Server(conf: ServiceConf) { @@ -23,182 +22,35 @@ case class Server(conf: ServiceConf) { private[this] val logger = getLogger - val mimeTypes = conf.mimeTypes - val defaultMimeType = conf.defaultMimeType - val vHosts = conf.virtualHosts + val geminiHandler = new GeminiHandler(conf) val charsetDecoder = Charset.forName("utf-8").newDecoder() def decodeUTF8(value: ByteString): Either[Throwable, String] = Try(charsetDecoder.decode(value.toByteBuffer).toString()).toEither - def validPath(path: String): Boolean = - !path - .split('/') - .drop(1) - .foldLeft(List(0)) { - case (acc, "..") => acc.appended(acc.last - 1) - case (acc, ".") => acc.appended(acc.last) - case (acc, _) => acc.appended(acc.last + 1) - } - .exists(_ < 0) - - def guessMimeType(path: Path, params: Option[String]): String = - mimeTypes.fold { - List(".gmi", ".gemini") - .find(path.toString().endsWith(_)) - .fold { - Try(Files.probeContentType(path)).toOption match { - case Some(mime) if mime != null => mime - case _ => defaultMimeType - } - }(_ => "text/gemini") - } { types => - types - .find { - case (t, exts) => exts.exists(path.toString().endsWith(_)) - } - .fold(defaultMimeType) { case (t, _) => t } - } match { - case mime @ "text/gemini" => - params.fold(mime)(p => s"$mime; ${p.stripMargin(';').trim()}") - case mime => mime - } - def handleReq(req: String, remoteAddr: String): Response = (for { uri <- Try(URI.create(req)).toEither - resp <- Try( - ( - uri.getScheme(), - uri.getHost(), - uri.getPath().decode(), - vHosts.find(vh => - Some(vh.host.toLowerCase) == Option(uri.getHost()) - .map(_.toLowerCase) + scheme <- Try(uri.getScheme()).toEither + resp <- Try(scheme match { + case null => + logger.debug(s"no scheme") + BadRequest(req) + case _ if uri.getPort() != -1 && uri.getPort() != conf.port => + logger.debug(s"invalid port, is a proxy request") + ProxyRequestRefused(req) + case _ if uri.getPort() == -1 && conf.port != Server.defPort => + logger.debug( + s"default port but non default was configured, is a proxy request" ) - ) match { - case (null, _, _, _) => - logger.debug(s"no scheme") - BadRequest(req) - case _ if uri.getPort() != -1 && uri.getPort() != conf.port => - logger.debug(s"invalid port, is a proxy request") - ProxyRequestRefused(req) - case _ if uri.getPort() == -1 && conf.port != Server.defPort => - logger.debug( - s"default port but non default was configured, is a proxy request" - ) - ProxyRequestRefused(req) - case ("gemini", host, _, None) => - logger.debug(s"vhost $host not found in $vHosts") - ProxyRequestRefused(req) - case ("gemini", host, _, _) if uri.getUserInfo() != null => - logger.debug(s"user info present") - BadRequest(req, "Userinfo component is not allowed") - case ("gemini", _, path, _) if !validPath(path) => - logger.debug("invalid path, out of root") - BadRequest(req) - case ("gemini", _, _, _) if uri.normalize() != uri => - logger.debug("redirect to normalize uri") - PermanentRedirect(req, uri.normalize().toString()) - case ("gemini", host, rawPath, Some(vhost)) => - val (root, path) = vhost.getRoot(rawPath) - - val resource = FileSystems - .getDefault() - .getPath(root, path) - .normalize() - val cgi = vhost - .getCgi(resource) match { - case None => vhost.getCgi(resource.resolve(vhost.indexFile)) - case cgi => cgi - } - - logger.debug(s"requesting: '$resource', cgi is '$cgi'") - - resource.toFile() match { - case _ - if cgi - .map(_.toFile()) - .map(f => f.isFile() && f.canExecute()) - .getOrElse(false) => - logger.debug("is cgi, will execute") - - val cgiFile = cgi.get - val queryString = - if (uri.getQuery() == null) "" else uri.getQuery() - val pathInfo = - if (cgiFile.compareTo(resource) >= 0) "" - else - "/" + resource - .subpath( - cgiFile.getNameCount(), - resource.getNameCount() - ) - .toString() - - Cgi( - req, - filename = cgiFile.toString(), - queryString = queryString, - pathInfo = pathInfo, - scriptName = cgiFile.getFileName().toString(), - host = vhost.host, - port = conf.port.toString(), - remoteAddr = remoteAddr, - vhEnv = vhost.environment.getOrElse(Map()) - ) - case path if !path.exists() => - logger.debug("no resource") - NotFound(req) - case path if path.exists() && !path.canRead() => - logger.debug("no read permissions") - NotFound(req) - case file if file.getName().startsWith(".") => - logger.debug("dot file, ignored request") - NotFound(req) - case file if file.isFile() => - Success( - req, - meta = guessMimeType(resource, vhost.geminiParams), - bodySize = file.length(), - bodyPath = Some(resource) - ) - case dir - if dir.isDirectory() && !path.isEmpty() && !path - .endsWith("/") => - logger.debug("redirect directory") - PermanentRedirect(req, uri.toString() + "/") - case dir if dir.isDirectory() => - val dirFilePath = resource.resolve(vhost.indexFile) - val dirFile = dirFilePath.toFile() - - if (dirFile.isFile() && dirFile.canRead()) { - logger.debug(s"serving index file: $dirFilePath") - Success( - req, - meta = guessMimeType(dirFilePath, vhost.geminiParams), - bodySize = dirFile.length(), - bodyPath = Some(dirFilePath) - ) - } else if (vhost.getDirectoryListing(resource)) { - logger.debug("directory listing") - DirListing( - req, - meta = "text/gemini", - bodyPath = Some(resource), - uriPath = path - ) - } else - NotFound(req) - case _ => - logger.debug("default: other resource type") - NotFound(req) - } - case (scheme, _, _, _) => - logger.debug(s"scheme $scheme not allowed") - ProxyRequestRefused(req) - } - ).toEither + ProxyRequestRefused(req) + case "gemini" => + logger.debug(s"gemini request: $req") + geminiHandler.handle(req, uri, remoteAddr) + case _ => + logger.debug(s"scheme $scheme not allowed") + ProxyRequestRefused(req) + }).toEither } yield resp) match { case Left(error: IllegalArgumentException) => logger.debug(s"invalid request: ${error.getMessage()}") @@ -206,12 +58,11 @@ case class Server(conf: ServiceConf) { case Left(error) => logger.error(error)("Internal server error") PermanentFailure(req, "Internal server error") - case Right(resp) => resp } def serve = { - val certs = vHosts.map { vhost => + val certs = conf.virtualHosts.map { vhost => vhost.keyStore.fold( ( vhost.host, diff --git a/server/src/net/usebox/gemini/server/URIUtils.scala b/server/src/net/usebox/gemini/server/URIUtils.scala index 63c1534..76e2adf 100644 --- a/server/src/net/usebox/gemini/server/URIUtils.scala +++ b/server/src/net/usebox/gemini/server/URIUtils.scala @@ -2,9 +2,12 @@ package net.usebox.gemini.server import java.nio.charset.StandardCharsets import java.net.{URLEncoder, URLDecoder} +import java.net.URI import scala.util.Try +import net.usebox.gemini.server.VirtualHost + object URIUtils { // FIXME: decoding/encoding errors implicit class StringOps(s: String) { @@ -15,5 +18,23 @@ object URIUtils { def decode(): String = Try(URLDecoder.decode(s, StandardCharsets.UTF_8.name())).toOption .getOrElse(s) + + def isValidPath: Boolean = + !s.split('/') + .drop(1) + .foldLeft(List(0)) { + case (acc, "..") => acc.appended(acc.last - 1) + case (acc, ".") => acc.appended(acc.last) + case (acc, _) => acc.appended(acc.last + 1) + } + .exists(_ < 0) + } + + implicit class UriOps(uri: URI) { + def toVirtualHost(vHosts: List[VirtualHost]): Option[VirtualHost] = + vHosts.find(vh => + Some(vh.host.toLowerCase) == Option(uri.getHost()) + .map(_.toLowerCase) + ) } } diff --git a/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala b/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala new file mode 100644 index 0000000..b582d55 --- /dev/null +++ b/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala @@ -0,0 +1,129 @@ +package net.usebox.gemini.server.handlers + +import java.net.URI +import java.nio.file.FileSystems + +import org.log4s._ + +import net.usebox.gemini.server._ +import URIUtils._ + +class GeminiHandler(conf: ServiceConf) extends ProtocolHandler(conf) { + + private[this] val logger = getLogger + + def handle(req: String, uri: URI, remoteAddr: String): Response = + ( + uri.getHost(), + uri.getPath().decode(), + uri.toVirtualHost(vHosts) + ) match { + case (host, _, None) => + logger.debug(s"vhost $host not found in $vHosts") + ProxyRequestRefused(req) + case (host, _, _) if uri.getUserInfo() != null => + logger.debug(s"user info present") + BadRequest(req, "Userinfo component is not allowed") + case (_, path, _) if !path.isValidPath => + logger.debug("invalid path, out of root") + BadRequest(req) + case _ if uri.normalize() != uri => + logger.debug("redirect to normalize uri") + PermanentRedirect(req, uri.normalize().toString()) + case (host, rawPath, Some(vhost)) => + val (root, path) = vhost.getRoot(rawPath) + + val resource = FileSystems + .getDefault() + .getPath(root, path) + .normalize() + val cgi = vhost + .getCgi(resource) match { + case None => vhost.getCgi(resource.resolve(vhost.indexFile)) + case cgi => cgi + } + + logger.debug(s"requesting: '$resource', cgi is '$cgi'") + + resource.toFile() match { + case _ + if cgi + .map(_.toFile()) + .map(f => f.isFile() && f.canExecute()) + .getOrElse(false) => + logger.debug("is cgi, will execute") + + val cgiFile = cgi.get + val queryString = + if (uri.getQuery() == null) "" else uri.getQuery() + val pathInfo = + if (cgiFile.compareTo(resource) >= 0) "" + else + "/" + resource + .subpath( + cgiFile.getNameCount(), + resource.getNameCount() + ) + .toString() + + Cgi( + req, + filename = cgiFile.toString(), + queryString = queryString, + pathInfo = pathInfo, + scriptName = cgiFile.getFileName().toString(), + host = vhost.host, + port = conf.port.toString(), + remoteAddr = remoteAddr, + vhEnv = vhost.environment.getOrElse(Map()) + ) + case path if !path.exists() => + logger.debug("no resource") + NotFound(req) + case path if path.exists() && !path.canRead() => + logger.debug("no read permissions") + NotFound(req) + case file if file.getName().startsWith(".") => + logger.debug("dot file, ignored request") + NotFound(req) + case file if file.isFile() => + Success( + req, + meta = guessMimeType(resource, vhost.geminiParams), + bodySize = file.length(), + bodyPath = Some(resource) + ) + case dir + if dir.isDirectory() && !path.isEmpty() && !path + .endsWith("/") => + logger.debug("redirect directory") + PermanentRedirect(req, uri.toString() + "/") + case dir if dir.isDirectory() => + val dirFilePath = resource.resolve(vhost.indexFile) + val dirFile = dirFilePath.toFile() + + if (dirFile.isFile() && dirFile.canRead()) { + logger.debug(s"serving index file: $dirFilePath") + Success( + req, + meta = guessMimeType(dirFilePath, vhost.geminiParams), + bodySize = dirFile.length(), + bodyPath = Some(dirFilePath) + ) + } else if (vhost.getDirectoryListing(resource)) { + logger.debug("directory listing") + DirListing( + req, + meta = "text/gemini", + bodyPath = Some(resource), + uriPath = path + ) + } else + NotFound(req) + case _ => + logger.debug("default: other resource type") + NotFound(req) + } + } + +} diff --git a/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala b/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala new file mode 100644 index 0000000..f36dc7f --- /dev/null +++ b/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala @@ -0,0 +1,38 @@ +package net.usebox.gemini.server.handlers + +import java.net.URI +import java.nio.file.{Path, Files} + +import scala.util.Try + +import net.usebox.gemini.server.{ServiceConf, Response} + +abstract class ProtocolHandler(conf: ServiceConf) { + + private val defaultMimeType = conf.defaultMimeType + val vHosts = conf.virtualHosts + + def guessMimeType(path: Path, params: Option[String]): String = + conf.mimeTypes.fold { + List(".gmi", ".gemini") + .find(path.toString().endsWith(_)) + .fold { + Try(Files.probeContentType(path)).toOption match { + case Some(mime) if mime != null => mime + case _ => defaultMimeType + } + }(_ => "text/gemini") + } { types => + types + .find { + case (t, exts) => exts.exists(path.toString().endsWith(_)) + } + .fold(defaultMimeType) { case (t, _) => t } + } match { + case mime @ "text/gemini" => + params.fold(mime)(p => s"$mime; ${p.stripMargin(';').trim()}") + case mime => mime + } + + def handle(req: String, uri: URI, remoteAddr: String): Response +} diff --git a/server/test/src/ServerSpec.scala b/server/test/src/ServerSpec.scala index 7d8c1fa..a7a2db0 100644 --- a/server/test/src/ServerSpec.scala +++ b/server/test/src/ServerSpec.scala @@ -1,115 +1,11 @@ package net.usebox.gemini.server -import java.nio.file.FileSystems - -import scala.concurrent.duration._ - import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import akka.util.ByteString class ServerSpec extends AnyFlatSpec with Matchers { - def getPath(value: String) = FileSystems.getDefault().getPath(value) - - behavior of "validPath" - - it should "return true for the emtpy path" in { - Server(TestData.conf).validPath("") shouldBe true - } - - it should "return true for valid paths" in { - List("/", "/file", "/./", "/.", "/dir/", "/dir/../").foreach { p => - Server(TestData.conf).validPath(p) shouldBe true - } - } - - it should "return false for invalid paths" in { - List("/../", "/..", "/dir/../..", "/dir/../..", "/./../", "/./dir/.././../") - .foreach { p => - Server(TestData.conf).validPath(p) shouldBe false - } - } - - behavior of "guessMimeType using the internal resolver" - - it should "resolve a known MIME type" in { - Server(TestData.conf) - .guessMimeType( - getPath("file.html"), - None - ) shouldBe "text/html" - } - - it should "resolve de default MIME type for unknown types" in { - Server(TestData.conf) - .guessMimeType( - getPath("unknow"), - None - ) shouldBe TestData.conf.defaultMimeType - } - - it should "resolve gemini MIME type" in { - Server(TestData.conf) - .guessMimeType( - getPath("file.gmi"), - None - ) shouldBe "text/gemini" - Server(TestData.conf) - .guessMimeType( - getPath("file.gemini"), - None - ) shouldBe "text/gemini" - } - - it should "resolve gemini MIME type, including parameters" in { - Server(TestData.conf) - .guessMimeType( - getPath("file.gmi"), - Some("param") - ) shouldBe "text/gemini; param" - Server(TestData.conf) - .guessMimeType( - getPath("file.gemini"), - Some("param") - ) shouldBe "text/gemini; param" - } - - it should "gemini MIME type parameters are sanitized" in { - Server(TestData.conf) - .guessMimeType( - getPath("file.gmi"), - Some(" ; param") - ) shouldBe "text/gemini; param" - } - - behavior of "guessMimeType using the configured types" - - it should "resolve a known MIME type" in { - Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) - .guessMimeType( - getPath("file.gmi"), - None - ) shouldBe "config" - } - - it should "include parameters for text/gemini MIME types" in { - Server( - TestData.conf.copy(mimeTypes = Some(Map("text/gemini" -> List(".gmi")))) - ).guessMimeType( - getPath("file.gmi"), - Some("param") - ) shouldBe "text/gemini; param" - } - - it should "resolve de default MIME type for unknown types" in { - Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) - .guessMimeType( - getPath("unknow"), - None - ) shouldBe TestData.conf.defaultMimeType - } - behavior of "decodeUTF8" it should "return right on valid UTF-8 codes" in { @@ -706,109 +602,4 @@ class ServerSpec extends AnyFlatSpec with Matchers { cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") cgi.body should include("PATH_INFO=/path/info") } - - object TestData { - - val host = "localhost" - val port = 1965 - val portStr = port.toString() - - val conf = ServiceConf( - address = "127.0.0.1", - port = port, - defaultMimeType = "text/plain", - idleTimeout = 10.seconds, - virtualHosts = List( - VirtualHost( - host = host, - root = getClass.getResource("/").getPath() - ) - ), - genCertValidFor = 1.day, - enabledProtocols = Nil, - enabledCipherSuites = Nil - ) - - val cgiConf = ServiceConf.initConf( - conf.copy(virtualHosts = - List( - conf - .virtualHosts(0) - .copy( - directoryListing = true, - directories = List( - Directory( - "dir/", - directoryListing = Some(false), - allowCgi = Some(true) - ) - ) - ) - ) - ) - ) - - val cgiPrefConf = ServiceConf.initConf( - conf.copy(virtualHosts = - List( - conf - .virtualHosts(0) - .copy( - directoryListing = true, - directories = List( - Directory( - "dir/", - directoryListing = Some(false), - allowCgi = Some(true) - ), - Directory( - "dir/sub/", - directoryListing = Some(false), - allowCgi = Some(true) - ) - ) - ) - ) - ) - ) - - val cgiEnvConf = cgiConf.copy(virtualHosts = - List( - cgiConf - .virtualHosts(0) - .copy( - environment = Some(Map("env1" -> "value")) - ) - ) - ) - - val cgiIndexConf = cgiConf.copy(virtualHosts = - List( - cgiConf - .virtualHosts(0) - .copy( - indexFile = "cgi" - ) - ) - ) - - val confUserDir = conf.copy(virtualHosts = - List( - conf - .virtualHosts(0) - .copy( - userDirectories = true, - userDirectoryPath = Some( - getClass.getResource("/").getPath() + "{user}/public_gemini/" - ) - ) - ) - ) - - val mimeTypes = Some( - Map( - "config" -> List(".gmi", ".gemini") - ) - ) - } } diff --git a/server/test/src/TestData.scala b/server/test/src/TestData.scala new file mode 100644 index 0000000..df3fbd6 --- /dev/null +++ b/server/test/src/TestData.scala @@ -0,0 +1,108 @@ +package net.usebox.gemini.server + +import scala.concurrent.duration._ + +object TestData { + + val host = "localhost" + val port = 1965 + val portStr = port.toString() + + val conf = ServiceConf( + address = "127.0.0.1", + port = port, + defaultMimeType = "text/plain", + idleTimeout = 10.seconds, + virtualHosts = List( + VirtualHost( + host = host, + root = getClass.getResource("/").getPath() + ) + ), + genCertValidFor = 1.day, + enabledProtocols = Nil, + enabledCipherSuites = Nil + ) + + val cgiConf = ServiceConf.initConf( + conf.copy(virtualHosts = + List( + conf + .virtualHosts(0) + .copy( + directoryListing = true, + directories = List( + Directory( + "dir/", + directoryListing = Some(false), + allowCgi = Some(true) + ) + ) + ) + ) + ) + ) + + val cgiPrefConf = ServiceConf.initConf( + conf.copy(virtualHosts = + List( + conf + .virtualHosts(0) + .copy( + directoryListing = true, + directories = List( + Directory( + "dir/", + directoryListing = Some(false), + allowCgi = Some(true) + ), + Directory( + "dir/sub/", + directoryListing = Some(false), + allowCgi = Some(true) + ) + ) + ) + ) + ) + ) + + val cgiEnvConf = cgiConf.copy(virtualHosts = + List( + cgiConf + .virtualHosts(0) + .copy( + environment = Some(Map("env1" -> "value")) + ) + ) + ) + + val cgiIndexConf = cgiConf.copy(virtualHosts = + List( + cgiConf + .virtualHosts(0) + .copy( + indexFile = "cgi" + ) + ) + ) + + val confUserDir = conf.copy(virtualHosts = + List( + conf + .virtualHosts(0) + .copy( + userDirectories = true, + userDirectoryPath = Some( + getClass.getResource("/").getPath() + "{user}/public_gemini/" + ) + ) + ) + ) + + val mimeTypes = Some( + Map( + "config" -> List(".gmi", ".gemini") + ) + ) +} diff --git a/server/test/src/URIUtilsSpec.scala b/server/test/src/URIUtilsSpec.scala new file mode 100644 index 0000000..ab00c86 --- /dev/null +++ b/server/test/src/URIUtilsSpec.scala @@ -0,0 +1,26 @@ +package net.usebox.gemini.server + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import URIUtils._ + +class URIUtilsSpec extends AnyFlatSpec with Matchers { + + behavior of "validPath" + + it should "return true for the emtpy path" in { + "".isValidPath shouldBe true + } + + it should "return true for valid paths" in { + List("/", "/file", "/./", "/.", "/dir/", "/dir/../").foreach( + _.isValidPath shouldBe true + ) + } + + it should "return false for invalid paths" in { + List("/../", "/..", "/dir/../..", "/dir/../..", "/./../", "/./dir/.././../") + .foreach(_.isValidPath shouldBe false) + } +} diff --git a/server/test/src/handlers/ProtocolHandlerSpec.scala b/server/test/src/handlers/ProtocolHandlerSpec.scala new file mode 100644 index 0000000..d35c9f2 --- /dev/null +++ b/server/test/src/handlers/ProtocolHandlerSpec.scala @@ -0,0 +1,100 @@ +package net.usebox.gemini.server.handlers + +import java.nio.file.FileSystems +import java.net.URI + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import net.usebox.gemini.server.{ServiceConf, Response} +import net.usebox.gemini.server.TestData + +class ProtocolHandlerSpec extends AnyFlatSpec with Matchers { + + def getPath(value: String) = FileSystems.getDefault().getPath(value) + + class TestHandler(conf: ServiceConf) extends ProtocolHandler(conf) { + def handle(req: String, uri: URI, remoteAddr: String): Response = ??? + } + + val handler = new TestHandler(TestData.conf) + + behavior of "guessMimeType using the internal resolver" + + it should "resolve a known MIME type" in { + handler + .guessMimeType( + getPath("file.html"), + None + ) shouldBe "text/html" + } + + it should "resolve de default MIME type for unknown types" in { + handler + .guessMimeType( + getPath("unknow"), + None + ) shouldBe TestData.conf.defaultMimeType + } + + it should "resolve gemini MIME type" in { + handler + .guessMimeType( + getPath("file.gmi"), + None + ) shouldBe "text/gemini" + handler + .guessMimeType( + getPath("file.gemini"), + None + ) shouldBe "text/gemini" + } + + it should "resolve gemini MIME type, including parameters" in { + handler + .guessMimeType( + getPath("file.gmi"), + Some("param") + ) shouldBe "text/gemini; param" + handler + .guessMimeType( + getPath("file.gemini"), + Some("param") + ) shouldBe "text/gemini; param" + } + + it should "gemini MIME type parameters are sanitized" in { + handler + .guessMimeType( + getPath("file.gmi"), + Some(" ; param") + ) shouldBe "text/gemini; param" + } + + behavior of "guessMimeType using the configured types" + + it should "resolve a known MIME type" in { + new TestHandler(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) + .guessMimeType( + getPath("file.gmi"), + None + ) shouldBe "config" + } + + it should "include parameters for text/gemini MIME types" in { + new TestHandler( + TestData.conf.copy(mimeTypes = Some(Map("text/gemini" -> List(".gmi")))) + ).guessMimeType( + getPath("file.gmi"), + Some("param") + ) shouldBe "text/gemini; param" + } + + it should "resolve de default MIME type for unknown types" in { + new TestHandler(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) + .guessMimeType( + getPath("unknow"), + None + ) shouldBe TestData.conf.defaultMimeType + } +} -- cgit v1.2.3 From 4d00bf26798ea328e9483c2140b45cfdd6fafe04 Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sun, 16 Jan 2022 00:30:29 +0000 Subject: Removed nulls --- server/src/net/usebox/gemini/server/Server.scala | 6 +++--- server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala | 6 ++---- server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala | 6 +++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala index d474bd3..96cf5ef 100644 --- a/server/src/net/usebox/gemini/server/Server.scala +++ b/server/src/net/usebox/gemini/server/Server.scala @@ -31,9 +31,9 @@ case class Server(conf: ServiceConf) { def handleReq(req: String, remoteAddr: String): Response = (for { uri <- Try(URI.create(req)).toEither - scheme <- Try(uri.getScheme()).toEither + scheme <- Try(Option(uri.getScheme())).toEither resp <- Try(scheme match { - case null => + case None => logger.debug(s"no scheme") BadRequest(req) case _ if uri.getPort() != -1 && uri.getPort() != conf.port => @@ -44,7 +44,7 @@ case class Server(conf: ServiceConf) { s"default port but non default was configured, is a proxy request" ) ProxyRequestRefused(req) - case "gemini" => + case Some("gemini") => logger.debug(s"gemini request: $req") geminiHandler.handle(req, uri, remoteAddr) case _ => diff --git a/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala b/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala index b582d55..ad5272c 100644 --- a/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala +++ b/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala @@ -21,7 +21,7 @@ class GeminiHandler(conf: ServiceConf) extends ProtocolHandler(conf) { case (host, _, None) => logger.debug(s"vhost $host not found in $vHosts") ProxyRequestRefused(req) - case (host, _, _) if uri.getUserInfo() != null => + case (host, _, _) if Option(uri.getUserInfo()).isDefined => logger.debug(s"user info present") BadRequest(req, "Userinfo component is not allowed") case (_, path, _) if !path.isValidPath => @@ -54,8 +54,7 @@ class GeminiHandler(conf: ServiceConf) extends ProtocolHandler(conf) { logger.debug("is cgi, will execute") val cgiFile = cgi.get - val queryString = - if (uri.getQuery() == null) "" else uri.getQuery() + val queryString = Option(uri.getQuery()).getOrElse("") val pathInfo = if (cgiFile.compareTo(resource) >= 0) "" else @@ -125,5 +124,4 @@ class GeminiHandler(conf: ServiceConf) extends ProtocolHandler(conf) { NotFound(req) } } - } diff --git a/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala b/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala index f36dc7f..7c85850 100644 --- a/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala +++ b/server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala @@ -17,9 +17,9 @@ abstract class ProtocolHandler(conf: ServiceConf) { List(".gmi", ".gemini") .find(path.toString().endsWith(_)) .fold { - Try(Files.probeContentType(path)).toOption match { - case Some(mime) if mime != null => mime - case _ => defaultMimeType + Try(Option(Files.probeContentType(path))).toOption.flatten match { + case Some(mime) => mime + case _ => defaultMimeType } }(_ => "text/gemini") } { types => -- cgit v1.2.3 From ada8e769780fbaa3294e36a732c8711790155ae3 Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sun, 16 Jan 2022 13:48:33 +0000 Subject: Don't format on compile --- build.sc | 6 ------ 1 file changed, 6 deletions(-) diff --git a/build.sc b/build.sc index 83a7087..1afcfbd 100644 --- a/build.sc +++ b/build.sc @@ -53,11 +53,5 @@ object server extends ScalaModule with ScalafmtModule with BuildInfo { object test extends Tests with TestModule.ScalaTest with ScalafmtModule { def ivyDeps = Agg(ivy"org.scalatest::scalatest:3.2.10") - - override def compile = - T { - reformat().apply() - super.compile() - } } } -- cgit v1.2.3 From 66deff10063ff49bcf0443ca3cc2b7f4639857cc Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sun, 16 Jan 2022 13:57:22 +0000 Subject: Move tests to the right spec --- server/test/src/ServerSpec.scala | 562 +---------------------- server/test/src/handlers/GeminiHandlerSpec.scala | 528 +++++++++++++++++++++ 2 files changed, 542 insertions(+), 548 deletions(-) create mode 100644 server/test/src/handlers/GeminiHandlerSpec.scala diff --git a/server/test/src/ServerSpec.scala b/server/test/src/ServerSpec.scala index a7a2db0..cfb613a 100644 --- a/server/test/src/ServerSpec.scala +++ b/server/test/src/ServerSpec.scala @@ -24,49 +24,6 @@ class ServerSpec extends AnyFlatSpec with Matchers { behavior of "handleReq" - it should "return bad request on URLs with no scheme" in { - Server(TestData.conf) - .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest]) - } - - it should "return proxy request refused on port mismatch" in { - Server(TestData.conf) - .handleReq("gemini://localhost:8080/", "127.0.0.1") should be( - a[ProxyRequestRefused] - ) - } - - it should "return proxy request refused when port not provided and configured port is not default" in { - Server(TestData.conf.copy(port = 8080)) - .handleReq("gemini://localhost/", "127.0.0.1") should be( - a[ProxyRequestRefused] - ) - } - - it should "return success when port is provided and matches configured port (not default)" in { - Server(TestData.conf.copy(port = 8080)) - .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success]) - } - - it should "handle host case insensitive" in { - Server(TestData.conf) - .handleReq("gemini://LOCALHOST/", "127.0.0.1") should be(a[Success]) - } - - it should "return proxy request refused when the vhost is not found" in { - Server(TestData.conf) - .handleReq("gemini://otherhost/", "127.0.0.1") should be( - a[ProxyRequestRefused] - ) - } - - it should "return bad request when user info is present" in { - Server(TestData.conf) - .handleReq("gemini://user@localhost/", "127.0.0.1") should be( - a[BadRequest] - ) - } - it should "return bad request on empty URLs" in { Server(TestData.conf) .handleReq("", "127.0.0.1") should be( @@ -74,66 +31,35 @@ class ServerSpec extends AnyFlatSpec with Matchers { ) } - it should "return bad request when the path is out of root dir" in { + it should "return bad request for invalid URLs" in { Server(TestData.conf) - .handleReq("gemini://localhost/../../", "127.0.0.1") should be( + .handleReq("gemini://localhost/ invalid", "127.0.0.1") should be( a[BadRequest] ) } - it should "return bad request for invalid URLs" in { + it should "return bad request on URLs with no scheme" in { Server(TestData.conf) - .handleReq( - "gemini://localhost/ invalid", - "127.0.0.1" - ) should be(a[BadRequest]) + .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest]) } - it should "redirect to normalize the URL" in { + it should "return proxy request refused on port mismatch" in { Server(TestData.conf) - .handleReq("gemini://localhost/./", "127.0.0.1") should be( - a[PermanentRedirect] + .handleReq("gemini://localhost:8080/", "127.0.0.1") should be( + a[ProxyRequestRefused] ) } - it should "return not found if the path doesn't exist" in { - Server(TestData.conf) - .handleReq( - "gemini://localhost/doesnotexist", - "127.0.0.1" - ) should be(a[NotFound]) - } - - it should "return not found if a dot file" in { - Server(TestData.conf) - .handleReq( - "gemini://localhost/.dotfile", - "127.0.0.1" - ) should be(a[NotFound]) - } - - it should "return success on reading file" in { - Server(TestData.conf) - .handleReq( - "gemini://localhost/index.gmi", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/gemini", Some(_), 25L) => - } - } - - it should "redirect and normalize request on a directory" in { - Server(TestData.conf) - .handleReq("gemini://localhost/dir", "127.0.0.1") should be( - a[PermanentRedirect] + it should "return proxy request refused when port not provided and configured port is not default" in { + Server(TestData.conf.copy(port = 8080)) + .handleReq("gemini://localhost/", "127.0.0.1") should be( + a[ProxyRequestRefused] ) } - it should "return an existing index file when requesting a directory" in { - Server(TestData.conf) - .handleReq("gemini://localhost/", "127.0.0.1") should matchPattern { - case Success(_, "text/gemini", Some(_), 25L) => - } + it should "return success when port is provided and matches configured port (not default)" in { + Server(TestData.conf.copy(port = 8080)) + .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success]) } it should "return proxy request refused for non gemini schemes" in { @@ -142,464 +68,4 @@ class ServerSpec extends AnyFlatSpec with Matchers { a[ProxyRequestRefused] ) } - - it should "include gemini params for gemini MIME type" in { - Server( - TestData.conf.copy(virtualHosts = - List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test"))) - ) - ).handleReq( - "gemini://localhost/index.gmi", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/gemini; test", Some(_), 25L) => - } - } - - behavior of "handleReq, directory listings" - - it should "return a directory listing if is enabled and no index" in { - Server(TestData.conf) - .handleReq("gemini://localhost/dir/", "127.0.0.1") should be( - a[DirListing] - ) - } - - it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in { - Server( - ServiceConf.initConf( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy( - directoryListing = false, - directories = List( - Directory( - "dir/", - directoryListing = Some(true), - allowCgi = None - ) - ) - ) - ) - ) - ) - ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[DirListing]) - } - - it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in { - Server( - ServiceConf.initConf( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy( - directoryListing = true, - directories = List( - Directory( - "dir/", - directoryListing = Some(false), - allowCgi = None - ) - ) - ) - ) - ) - ) - ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) - } - - it should "not apply directory listing override to subdirectories" in { - Server( - ServiceConf.initConf( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy( - directoryListing = false, - directories = List( - Directory( - "dir/", - directoryListing = Some(true), - allowCgi = None - ) - ) - ) - ) - ) - ) - ).handleReq("gemini://localhost/dir/sub/", "127.0.0.1") should be( - a[NotFound] - ) - } - - it should "return not found if directory listing is not enabled and no index" in { - Server( - TestData.conf.copy(virtualHosts = - List(TestData.conf.virtualHosts(0).copy(directoryListing = false)) - ) - ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) - } - - behavior of "handleReq, user directories" - - it should "return success on reading file" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/index.gmi", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/gemini", Some(_), 38L) => - } - } - - it should "return redirect accessing the user directory without ending slash" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username", - "127.0.0.1" - ) should be(a[PermanentRedirect]) - } - - it should "return success accessing the user directory index" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/gemini", Some(_), 38L) => - } - } - - it should "return bad request trying to exit the root directory" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/../../", - "127.0.0.1" - ) should be(a[BadRequest]) - } - - it should "return redirect to the virtual host root when leaving the user dir" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/../", - "127.0.0.1" - ) should be(a[PermanentRedirect]) - } - - it should "not translate root if used an invalid user pattern" in { - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username../", - "127.0.0.1" - ) should be(a[NotFound]) - - Server(TestData.confUserDir).handleReq( - "gemini://localhost/~0invalid/", - "127.0.0.1" - ) should be(a[NotFound]) - } - - it should "not translate root if no user directory path was provided" in { - Server( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy(userDirectories = true) - ) - ) - ).handleReq( - "gemini://localhost/~username/", - "127.0.0.1" - ) should be(a[NotFound]) - } - - it should "not execute a CGI if the target resource is not executable" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/file.txt", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/plain", Some(_), 5L) => - } - } - - it should "not execute a CGI if the target resource is a directory" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/sub/", - "127.0.0.1" - ) should be(a[DirListing]) - } - - it should "not apply allow CGI to subdirectories" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/sub/cgi", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/plain", Some(_), 72) => - } - } - - it should "execute a CGI" in { - val cgi = Server(TestData.cgiConf) - .handleReq( - "gemini://localhost/dir/cgi", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") - } - - it should "execute a CGI: empty parameters, host and port" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/cgi", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - "", - "", - "cgi", - TestData.host, - TestData.portStr, - _, - _ - ) => - } - } - - it should "execute a CGI: host is case-insensitive and the value in the conf is used" in { - Server(TestData.cgiConf).handleReq( - "gemini://LOCALHOST/dir/cgi", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - "", - "", - "cgi", - TestData.host, - TestData.portStr, - _, - _ - ) => - } - } - - it should "execute a CGI: query string" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/cgi?query&string", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - "query&string", - "", - "cgi", - TestData.host, - TestData.portStr, - _, - _ - ) => - } - } - - it should "execute a CGI: path info" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/cgi/path/info", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - "", - "/path/info", - "cgi", - TestData.host, - TestData.portStr, - _, - _ - ) => - } - } - - it should "execute a CGI: query string and path info" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/cgi/path/info?query=string", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - "query=string", - "/path/info", - "cgi", - TestData.host, - TestData.portStr, - _, - _ - ) => - } - } - - it should "not execute an executable if allow CGI is off" in { - Server(TestData.conf) - .handleReq( - "gemini://localhost/dir/cgi", - "127.0.0.1" - ) should matchPattern { - case Success(_, "text/plain", Some(_), _) => - } - } - - it should "response with an error if the CGI exits with non 0" in { - val bad = Server(TestData.cgiConf) - .handleReq( - "gemini://localhost/dir/bad-cgi", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - val meta = "Error executing CGI" - bad.status should be(50) - bad.meta should be(meta) - bad.body should include(meta) - } - - it should "return a response with an error if the CGI exits with non 0" in { - val bad = Server(TestData.cgiConf) - .handleReq( - "gemini://localhost/dir/bad-cgi", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - val meta = "Error executing CGI" - bad.status should be(50) - bad.meta should be(meta) - bad.body should include(meta) - } - - it should "return a response with an error if the CGI response is invalid" in { - val bad = Server(TestData.cgiConf) - .handleReq( - "gemini://localhost/dir/bad-response", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - val meta = "Invalid response from CGI" - bad.status should be(40) - bad.meta should be(meta) - bad.body should include(meta) - } - - it should "environment variables are optional" in { - Server(TestData.cgiConf).handleReq( - "gemini://localhost/dir/cgi/", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - _, - _, - "cgi", - TestData.host, - TestData.portStr, - _, - m - ) if m == Map() => - } - } - - it should "pass environment variables to the CGI" in { - Server(TestData.cgiEnvConf).handleReq( - "gemini://localhost/dir/cgi/", - "127.0.0.1" - ) should matchPattern { - case Cgi( - _, - _, - _, - _, - "cgi", - TestData.host, - TestData.portStr, - _, - m - ) if m == Map("env1" -> "value") => - } - } - - it should "execute a CGI with the environment variables" in { - val cgi = Server(TestData.cgiEnvConf) - .handleReq( - "gemini://localhost/dir/cgi", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("env1=value") - } - - it should "execute a CGI when it is the index document" in { - val cgi = Server(TestData.cgiIndexConf) - .handleReq( - "gemini://localhost/dir/", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") - } - - it should "execute a CGI when it is the index document (full name)" in { - val cgi = Server(TestData.cgiIndexConf) - .handleReq( - "gemini://localhost/dir/cgi", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") - } - - it should "execute a CGI when it is the index document (full name, path info)" in { - val cgi = Server(TestData.cgiIndexConf) - .handleReq( - "gemini://localhost/dir/cgi/path/info", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") - cgi.body should include("PATH_INFO=/path/info") - } - - it should "resolve CGI directories from more to less specific" in { - // issue: https://gitlab.com/reidrac/spacebeans/-/issues/2 - val cgi = Server(TestData.cgiPrefConf) - .handleReq( - "gemini://localhost/dir/sub/cgiOk/path/info", - "127.0.0.1" - ) - .asInstanceOf[Cgi] - - cgi.status should be(20) - cgi.meta should be("text/gemini") - cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") - cgi.body should include("PATH_INFO=/path/info") - } } diff --git a/server/test/src/handlers/GeminiHandlerSpec.scala b/server/test/src/handlers/GeminiHandlerSpec.scala new file mode 100644 index 0000000..6b177f8 --- /dev/null +++ b/server/test/src/handlers/GeminiHandlerSpec.scala @@ -0,0 +1,528 @@ +package net.usebox.gemini.server.handlers + +import java.net.URI + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import net.usebox.gemini.server._ + +class GeminiHandlerSpec extends AnyFlatSpec with Matchers { + + behavior of "handle" + + val handler = new GeminiHandler(TestData.conf) + val handlerUserDir = new GeminiHandler(TestData.confUserDir) + val handlerCgi = new GeminiHandler(TestData.cgiConf) + + def handleWith( + geminiHandler: GeminiHandler, + req: String, + remoteAddr: String = "127.0.0.1" + ): Response = + geminiHandler.handle(req, URI.create(req), remoteAddr) + + it should "handle host case insensitive" in { + handleWith(handler, "gemini://LOCALHOST/") should be(a[Success]) + } + + it should "return proxy request refused when the vhost is not found" in { + handleWith(handler, "gemini://otherhost/") should be( + a[ProxyRequestRefused] + ) + } + + it should "return bad request when user info is present" in { + handleWith(handler, "gemini://user@localhost/") should be( + a[BadRequest] + ) + } + + it should "return bad request when the path is out of root dir" in { + handleWith(handler, "gemini://localhost/../../") should be( + a[BadRequest] + ) + } + + it should "redirect to normalize the URL" in { + handleWith(handler, "gemini://localhost/./") should be( + a[PermanentRedirect] + ) + } + + it should "return not found if the path doesn't exist" in { + handleWith( + handler, + "gemini://localhost/doesnotexist" + ) should be(a[NotFound]) + } + + it should "return not found if a dot file" in { + handleWith(handler, "gemini://localhost/.dotfile") should be( + a[NotFound] + ) + } + + it should "return success on reading file" in { + handleWith( + handler, + "gemini://localhost/index.gmi" + ) should matchPattern { + case Success(_, "text/gemini", Some(_), 25L) => + } + } + + it should "redirect and normalize request on a directory" in { + handleWith(handler, "gemini://localhost/dir") should be( + a[PermanentRedirect] + ) + } + + it should "return an existing index file when requesting a directory" in { + handleWith(handler, "gemini://localhost/") should matchPattern { + case Success(_, "text/gemini", Some(_), 25L) => + } + } + + it should "include gemini params for gemini MIME type" in { + handleWith( + new GeminiHandler( + TestData.conf.copy(virtualHosts = + List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test"))) + ) + ), + "gemini://localhost/index.gmi" + ) should matchPattern { + case Success(_, "text/gemini; test", Some(_), 25L) => + } + } + + behavior of "handler, directory listings" + + it should "return a directory listing if is enabled and no index" in { + handleWith(handler, "gemini://localhost/dir/") should be( + a[DirListing] + ) + } + + it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in { + handleWith( + new GeminiHandler( + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = false, + directories = List( + Directory( + "dir/", + directoryListing = Some(true), + allowCgi = None + ) + ) + ) + ) + ) + ) + ), + "gemini://localhost/dir/" + ) should be(a[DirListing]) + } + + it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in { + handleWith( + new GeminiHandler( + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = true, + directories = List( + Directory( + "dir/", + directoryListing = Some(false), + allowCgi = None + ) + ) + ) + ) + ) + ) + ), + "gemini://localhost/dir/" + ) should be(a[NotFound]) + } + + it should "not apply directory listing override to subdirectories" in { + handleWith( + new GeminiHandler( + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = false, + directories = List( + Directory( + "dir/", + directoryListing = Some(true), + allowCgi = None + ) + ) + ) + ) + ) + ) + ), + "gemini://localhost/dir/sub/" + ) should be( + a[NotFound] + ) + } + + it should "return not found if directory listing is not enabled and no index" in { + handleWith( + new GeminiHandler( + TestData.conf.copy(virtualHosts = + List(TestData.conf.virtualHosts(0).copy(directoryListing = false)) + ) + ), + "gemini://localhost/dir/" + ) should be(a[NotFound]) + } + + behavior of "handler, user directories" + + it should "return success on reading file" in { + handleWith( + handlerUserDir, + "gemini://localhost/~username/index.gmi" + ) should matchPattern { + case Success(_, "text/gemini", Some(_), 38L) => + } + } + + it should "return redirect accessing the user directory without ending slash" in { + handleWith(handlerUserDir, "gemini://localhost/~username") should be( + a[PermanentRedirect] + ) + } + + it should "return success accessing the user directory index" in { + handleWith( + handlerUserDir, + "gemini://localhost/~username/" + ) should matchPattern { + case Success(_, "text/gemini", Some(_), 38L) => + } + } + + it should "return bad request trying to exit the root directory" in { + handleWith(handlerUserDir, "gemini://localhost/~username/../../") should be( + a[BadRequest] + ) + } + + it should "return redirect to the virtual host root when leaving the user dir" in { + handleWith(handlerUserDir, "gemini://localhost/~username/../") should be( + a[PermanentRedirect] + ) + } + + it should "not translate root if used an invalid user pattern" in { + handleWith(handlerUserDir, "gemini://localhost/~username../") should be( + a[NotFound] + ) + + handleWith(handlerUserDir, "gemini://localhost/~0invalid/") should be( + a[NotFound] + ) + } + + it should "not translate root if no user directory path was provided" in { + handleWith( + new GeminiHandler( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy(userDirectories = true) + ) + ) + ), + "gemini://localhost/~username/", + "127.0.0.1" + ) should be(a[NotFound]) + } + + it should "not execute a CGI if the target resource is not executable" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/file.txt" + ) should matchPattern { + case Success(_, "text/plain", Some(_), 5L) => + } + } + + it should "not execute a CGI if the target resource is a directory" in { + handleWith(handlerCgi, "gemini://localhost/dir/sub/") should be( + a[DirListing] + ) + } + + it should "not apply allow CGI to subdirectories" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/sub/cgi" + ) should matchPattern { + case Success(_, "text/plain", Some(_), 72) => + } + } + + it should "execute a CGI" in { + val cgi = handleWith(handlerCgi, "gemini://localhost/dir/cgi") + .asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + } + + it should "execute a CGI: empty parameters, host and port" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/cgi" + ) should matchPattern { + case Cgi( + _, + _, + "", + "", + "cgi", + TestData.host, + TestData.portStr, + _, + _ + ) => + } + } + + it should "execute a CGI: host is case-insensitive and the value in the conf is used" in { + handleWith( + handlerCgi, + "gemini://LOCALHOST/dir/cgi" + ) should matchPattern { + case Cgi( + _, + _, + "", + "", + "cgi", + TestData.host, + TestData.portStr, + _, + _ + ) => + } + } + + it should "execute a CGI: query string" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/cgi?query&string" + ) should matchPattern { + case Cgi( + _, + _, + "query&string", + "", + "cgi", + TestData.host, + TestData.portStr, + _, + _ + ) => + } + } + + it should "execute a CGI: path info" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/cgi/path/info" + ) should matchPattern { + case Cgi( + _, + _, + "", + "/path/info", + "cgi", + TestData.host, + TestData.portStr, + _, + _ + ) => + } + } + + it should "execute a CGI: query string and path info" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/cgi/path/info?query=string" + ) should matchPattern { + case Cgi( + _, + _, + "query=string", + "/path/info", + "cgi", + TestData.host, + TestData.portStr, + _, + _ + ) => + } + } + + it should "not execute an executable if allow CGI is off" in { + handleWith( + handler, + "gemini://localhost/dir/cgi" + ) should matchPattern { + case Success(_, "text/plain", Some(_), _) => + } + } + + it should "response with an error if the CGI exits with non 0" in { + val bad = + handleWith(handlerCgi, "gemini://localhost/dir/bad-cgi") + .asInstanceOf[Cgi] + + val meta = "Error executing CGI" + bad.status should be(50) + bad.meta should be(meta) + bad.body should include(meta) + } + + it should "return a response with an error if the CGI exits with non 0" in { + val bad = + handleWith(handlerCgi, "gemini://localhost/dir/bad-cgi") + .asInstanceOf[Cgi] + + val meta = "Error executing CGI" + bad.status should be(50) + bad.meta should be(meta) + bad.body should include(meta) + } + + it should "return a response with an error if the CGI response is invalid" in { + val bad = + handleWith(handlerCgi, "gemini://localhost/dir/bad-response") + .asInstanceOf[Cgi] + + val meta = "Invalid response from CGI" + bad.status should be(40) + bad.meta should be(meta) + bad.body should include(meta) + } + + it should "environment variables are optional" in { + handleWith( + handlerCgi, + "gemini://localhost/dir/cgi/" + ) should matchPattern { + case Cgi( + _, + _, + _, + _, + "cgi", + TestData.host, + TestData.portStr, + _, + m + ) if m == Map() => + } + } + + it should "pass environment variables to the CGI" in { + handleWith( + new GeminiHandler(TestData.cgiEnvConf), + "gemini://localhost/dir/cgi/" + ) should matchPattern { + case Cgi( + _, + _, + _, + _, + "cgi", + TestData.host, + TestData.portStr, + _, + m + ) if m == Map("env1" -> "value") => + } + } + + it should "execute a CGI with the environment variables" in { + val cgi = handleWith( + new GeminiHandler(TestData.cgiEnvConf), + "gemini://localhost/dir/cgi" + ).asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("env1=value") + } + + it should "execute a CGI when it is the index document" in { + val cgi = handleWith( + new GeminiHandler(TestData.cgiIndexConf), + "gemini://localhost/dir/" + ).asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + } + + it should "execute a CGI when it is the index document (full name)" in { + val cgi = handleWith( + new GeminiHandler(TestData.cgiIndexConf), + "gemini://localhost/dir/cgi" + ).asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + } + + it should "execute a CGI when it is the index document (full name, path info)" in { + val cgi = handleWith( + new GeminiHandler(TestData.cgiIndexConf), + "gemini://localhost/dir/cgi/path/info" + ).asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + cgi.body should include("PATH_INFO=/path/info") + } + + it should "resolve CGI directories from more to less specific" in { + // issue: https://gitlab.com/reidrac/spacebeans/-/issues/2 + val cgi = handleWith( + new GeminiHandler(TestData.cgiPrefConf), + "gemini://localhost/dir/sub/cgiOk/path/info" + ).asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + cgi.body should include("PATH_INFO=/path/info") + } +} -- cgit v1.2.3 From aee32d5f42560ad0ff5d47034da699b3e608d2b4 Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sun, 16 Jan 2022 13:58:09 +0000 Subject: Tidying --- server/test/src/handlers/GeminiHandlerSpec.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/test/src/handlers/GeminiHandlerSpec.scala b/server/test/src/handlers/GeminiHandlerSpec.scala index 6b177f8..f3545a0 100644 --- a/server/test/src/handlers/GeminiHandlerSpec.scala +++ b/server/test/src/handlers/GeminiHandlerSpec.scala @@ -9,8 +9,6 @@ import net.usebox.gemini.server._ class GeminiHandlerSpec extends AnyFlatSpec with Matchers { - behavior of "handle" - val handler = new GeminiHandler(TestData.conf) val handlerUserDir = new GeminiHandler(TestData.confUserDir) val handlerCgi = new GeminiHandler(TestData.cgiConf) @@ -22,6 +20,8 @@ class GeminiHandlerSpec extends AnyFlatSpec with Matchers { ): Response = geminiHandler.handle(req, URI.create(req), remoteAddr) + behavior of "handle" + it should "handle host case insensitive" in { handleWith(handler, "gemini://LOCALHOST/") should be(a[Success]) } -- cgit v1.2.3