aboutsummaryrefslogtreecommitdiff
path: root/server/src/net/usebox/gemini
diff options
context:
space:
mode:
authorJuan J. Martinez <jjm@usebox.net>2021-07-03 14:14:31 +0100
committerJuan J. Martinez <jjm@usebox.net>2021-07-22 19:49:53 +0100
commitc760d7c9bd14d7e56ae1553a0b3c4a1258448686 (patch)
treee60a35310a2a9f120a5c3e769ea862e950d322f5 /server/src/net/usebox/gemini
parentab4a5268bd2971a364bf026aede2cb50c885a03d (diff)
downloadspacebeans-c760d7c9bd14d7e56ae1553a0b3c4a1258448686.tar.gz
spacebeans-c760d7c9bd14d7e56ae1553a0b3c4a1258448686.zip
CGI support
Diffstat (limited to 'server/src/net/usebox/gemini')
-rw-r--r--server/src/net/usebox/gemini/server/Response.scala85
-rw-r--r--server/src/net/usebox/gemini/server/Server.scala63
-rw-r--r--server/src/net/usebox/gemini/server/ServiceConf.scala72
3 files changed, 181 insertions, 39 deletions
diff --git a/server/src/net/usebox/gemini/server/Response.scala b/server/src/net/usebox/gemini/server/Response.scala
index 6c243b2..a6993c4 100644
--- a/server/src/net/usebox/gemini/server/Response.scala
+++ b/server/src/net/usebox/gemini/server/Response.scala
@@ -2,6 +2,11 @@ package net.usebox.gemini.server
import java.nio.file.Path
+import scala.sys.process._
+import scala.util.Try
+
+import org.log4s._
+
import akka.stream.ActorAttributes
import akka.stream.scaladsl.{Source, FileIO}
import akka.util.ByteString
@@ -78,6 +83,86 @@ case class DirListing(
)
}
+case class Cgi(
+ req: String,
+ filename: String,
+ queryString: String,
+ pathInfo: String,
+ scriptName: String,
+ host: String,
+ port: String,
+ remoteAddr: String
+) extends Response {
+
+ private[this] val logger = getLogger
+
+ val bodyPath: Option[Path] = None
+
+ val responseRe = "([0-9]{2}) (.*)".r
+
+ val env = Map(
+ "GATEWAY_INTERFACE" -> "CGI/1.1",
+ "SERVER_SOFTWARE" -> s"${BuildInfo.name}/${BuildInfo.version}",
+ "SERVER_PROTOCOL" -> "GEMINI",
+ "GEMINI_URL" -> req,
+ "SCRIPT_NAME" -> scriptName,
+ "PATH_INFO" -> pathInfo,
+ "QUERY_STRING" -> queryString,
+ "SERVER_NAME" -> host,
+ "SERVER_PORT" -> port,
+ "REMOTE_ADDR" -> remoteAddr,
+ "REMOTE_HOST" -> remoteAddr
+ ).toSeq
+
+ val (status: Int, meta: String, body: String) = {
+ val output = new java.io.ByteArrayOutputStream
+ Try {
+ val jpb = new java.lang.ProcessBuilder(filename)
+ jpb.environment.clear()
+ env.foreach { case (k, v) => jpb.environment.put(k, v) }
+
+ val exit = (Process(jpb) #> output).!
+ output.close()
+
+ exit
+ }.toEither match {
+ case Right(0) =>
+ val body = output.toString("UTF-8")
+ body.split("\r\n").headOption match {
+ case Some(req @ responseRe(status, meta))
+ if req.length <= Server.maxReqLen =>
+ (status.toInt, meta, body)
+ case _ =>
+ logger.warn(s"$scriptName: invalid CGI response")
+ respError(40, "Invalid response from CGI")
+ }
+
+ case Right(exit) =>
+ logger.warn(s"$scriptName: failed to execute CGI (exit: $exit)")
+ respError(50, s"Error executing CGI")
+
+ case Left(error) =>
+ logger.warn(
+ s"$scriptName: failed to execute CGI (${error.getMessage()})"
+ )
+ respError(50, s"Error executing CGI")
+ }
+ }
+
+ def respError(
+ status: Int,
+ meta: String
+ ): (Int, String, String) = {
+ // response should fit in 1024 bytes: XX META\r\n
+ val limited = meta.substring(0, Math.min(meta.length, Server.maxReqLen - 5))
+ (status, meta, s"$status $limited\r\n")
+ }
+
+ def bodySize: Long = body.size
+
+ override def toSource = Source.single(ByteString(body))
+}
+
case class TempRedirect(
req: String,
meta: String = "Redirect - temporary"
diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala
index a250158..3c78721 100644
--- a/server/src/net/usebox/gemini/server/Server.scala
+++ b/server/src/net/usebox/gemini/server/Server.scala
@@ -23,9 +23,6 @@ case class Server(conf: ServiceConf) {
private[this] val logger = getLogger
- val defPort = 1965
- val maxReqLen = 1024
-
val mimeTypes = conf.mimeTypes
val defaultMimeType = conf.defaultMimeType
val vHosts = conf.virtualHosts
@@ -67,7 +64,7 @@ case class Server(conf: ServiceConf) {
case mime => mime
}
- def handleReq(req: String): Response =
+ def handleReq(req: String, remoteAddr: String): Response =
(for {
uri <- Try(URI.create(req)).toEither
resp <- Try(
@@ -83,7 +80,7 @@ case class Server(conf: ServiceConf) {
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 != defPort =>
+ case _ if uri.getPort() == -1 && conf.port != Server.defPort =>
logger.debug(
s"default port but non default was configured, is a proxy request"
)
@@ -107,10 +104,41 @@ case class Server(conf: ServiceConf) {
.getDefault()
.getPath(root, path)
.normalize()
+ val cgi = vhost.getCgi(resource)
- logger.debug(s"requesting: '$resource'")
+ logger.debug(s"requesting: '$resource', cgi is '$cgi'")
resource.toFile() match {
+ case file
+ 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
+ )
case path if !path.exists() =>
logger.debug("no resource")
NotFound(req)
@@ -227,15 +255,15 @@ case class Server(conf: ServiceConf) {
closing = TLSClosing.ignoreCancel
)
.runForeach { connection =>
- val remoteHost = connection.remoteAddress.getHostString()
- logger.debug(s"new connection $remoteHost")
+ val remoteAddr = connection.remoteAddress.getHostString()
+ logger.debug(s"new connection $remoteAddr")
val handler = Flow[ByteString]
.watchTermination() { (_, f) =>
f.onComplete {
_.toEither.swap.map(error =>
logger.warn(
- s"$remoteHost - stream terminated: ${error.getMessage()}"
+ s"$remoteAddr - stream terminated: ${error.getMessage()}"
)
)
}
@@ -244,7 +272,7 @@ case class Server(conf: ServiceConf) {
Framing
.delimiter(
ByteString("\r\n"),
- maximumFrameLength = maxReqLen + 1,
+ maximumFrameLength = Server.maxReqLen + 1,
allowTruncation = true
)
)
@@ -257,16 +285,16 @@ case class Server(conf: ServiceConf) {
logger.debug(s"invalid UTF-8 encoding: ${error.getMessage()}")
BadRequest(req.utf8String)
case Right(reqStr) =>
- if (req.size > maxReqLen)
+ if (req.size > Server.maxReqLen)
BadRequest(reqStr.take(1024) + "{...}")
else
- handleReq(reqStr)
+ handleReq(reqStr, remoteAddr)
}
}
.take(1)
.wireTap(resp =>
logger.info(
- s"""$remoteHost "${resp.req}" ${resp.status} ${resp.bodySize}"""
+ s"""$remoteAddr "${resp.req}" ${resp.status} ${resp.bodySize}"""
)
)
.flatMapConcat(_.toSource)
@@ -275,3 +303,12 @@ case class Server(conf: ServiceConf) {
}
}
}
+
+object Server {
+
+ /** Maximum request length in bytes. */
+ val maxReqLen = 1024
+
+ /** Default port. */
+ val defPort = 1965
+}
diff --git a/server/src/net/usebox/gemini/server/ServiceConf.scala b/server/src/net/usebox/gemini/server/ServiceConf.scala
index 74ed548..dc28650 100644
--- a/server/src/net/usebox/gemini/server/ServiceConf.scala
+++ b/server/src/net/usebox/gemini/server/ServiceConf.scala
@@ -11,7 +11,11 @@ import org.log4s._
case class KeyStore(path: String, alias: String, password: String)
-case class Directory(path: String, directoryListing: Option[Boolean])
+case class Directory(
+ path: String,
+ directoryListing: Option[Boolean],
+ allowCgi: Option[Boolean]
+)
case class VirtualHost(
host: String,
@@ -34,9 +38,24 @@ object VirtualHost {
def getDirectoryListing(path: Path): Boolean =
vhost.directories
.find(_.path == path.toString())
- .fold(vhost.directoryListing)(loc =>
- loc.directoryListing.getOrElse(vhost.directoryListing)
+ .flatMap(_.directoryListing)
+ .getOrElse(vhost.directoryListing)
+
+ def getCgi(path: Path): Option[Path] =
+ vhost.directories
+ .find(d =>
+ path.startsWith(
+ d.path
+ ) && path.toString != d.path && d.allowCgi == Some(true)
)
+ .collect {
+ case d =>
+ val dp =
+ FileSystems.getDefault().getPath(d.path).normalize()
+ FileSystems
+ .getDefault()
+ .getPath(d.path, path.getName(dp.getNameCount()).toString())
+ }
def getRoot(path: String): (String, String) =
path match {
@@ -75,32 +94,33 @@ object ServiceConf {
import VirtualHost.userTag
- def load(confFile: String) =
- ConfigSource.file(confFile).load[ServiceConf].map { conf =>
- conf.copy(virtualHosts = conf.virtualHosts.map { vhost =>
- if (
- vhost.userDirectories && !vhost.userDirectoryPath
- .fold(false)(dir => dir.contains(userTag))
+ def initConf(conf: ServiceConf): ServiceConf =
+ conf.copy(virtualHosts = conf.virtualHosts.map { vhost =>
+ if (
+ vhost.userDirectories && !vhost.userDirectoryPath
+ .fold(false)(dir => dir.contains(userTag))
+ )
+ logger.warn(
+ s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path"
)
- logger.warn(
- s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path"
- )
- vhost.copy(directories = vhost.directories.map { dir =>
- val path =
- FileSystems
- .getDefault()
- .getPath(vhost.root, dir.path)
- .normalize()
+ vhost.copy(directories = vhost.directories.map { dir =>
+ val path =
+ FileSystems
+ .getDefault()
+ .getPath(vhost.root, dir.path)
+ .normalize()
- if (!path.toFile().isDirectory())
- logger.warn(
- s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory"
- )
+ if (!path.toFile().isDirectory())
+ logger.warn(
+ s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory"
+ )
- dir
- .copy(path = path.toString())
- })
+ dir
+ .copy(path = path.toString())
})
- }
+ })
+
+ def load(confFile: String) =
+ ConfigSource.file(confFile).load[ServiceConf].map(initConf)
}