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 ++++ 4 files changed, 210 insertions(+), 171 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 (limited to 'server/src') 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 +} -- cgit v1.2.3