aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJuan J. Martínez <jjm@usebox.net>2022-01-20 21:44:34 +0000
committerJuan J. Martínez <jjm@usebox.net>2022-01-20 21:44:34 +0000
commitf90e7bad9f1df2d652b82048e9073096ddc8df18 (patch)
treec6f53976dfcb03cabf474a9c8528db44da762c2e
parent397bee70a0baa6ab7cc2115c3a5dd555b381a49d (diff)
parentaee32d5f42560ad0ff5d47034da699b3e608d2b4 (diff)
downloadspacebeans-f90e7bad9f1df2d652b82048e9073096ddc8df18.tar.gz
spacebeans-f90e7bad9f1df2d652b82048e9073096ddc8df18.zip
Merge branch 'protocol-refactor' into 'main'
Protocol refactor See merge request reidrac/spacebeans!2
-rw-r--r--build.sc6
-rw-r--r--server/src/net/usebox/gemini/server/Server.scala193
-rw-r--r--server/src/net/usebox/gemini/server/URIUtils.scala21
-rw-r--r--server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala127
-rw-r--r--server/src/net/usebox/gemini/server/handlers/ProtocolHandler.scala38
-rw-r--r--server/test/src/ServerSpec.scala771
-rw-r--r--server/test/src/TestData.scala108
-rw-r--r--server/test/src/URIUtilsSpec.scala26
-rw-r--r--server/test/src/handlers/GeminiHandlerSpec.scala528
-rw-r--r--server/test/src/handlers/ProtocolHandlerSpec.scala100
10 files changed, 984 insertions, 934 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()
- }
}
}
diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala
index 42a097c..96cf5ef 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(Option(uri.getScheme())).toEither
+ resp <- Try(scheme match {
+ case None =>
+ 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 Some("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..ad5272c
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/handlers/GeminiHandler.scala
@@ -0,0 +1,127 @@
+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 Option(uri.getUserInfo()).isDefined =>
+ 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 = Option(uri.getQuery()).getOrElse("")
+ 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..7c85850
--- /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(Option(Files.probeContentType(path))).toOption.flatten match {
+ case Some(mime) => 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..cfb613a 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 {
@@ -128,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(
@@ -178,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 {
@@ -246,569 +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")
- }
-
- 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/GeminiHandlerSpec.scala b/server/test/src/handlers/GeminiHandlerSpec.scala
new file mode 100644
index 0000000..f3545a0
--- /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 {
+
+ 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)
+
+ behavior of "handle"
+
+ 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")
+ }
+}
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
+ }
+}