From f85930d875494c043fc5ac3ddcf843ddfac14ec9 Mon Sep 17 00:00:00 2001
From: tjpcc <tjp@ctrl-c.club>
Date: Mon, 30 Oct 2023 11:57:04 -0600
Subject: spartan support in fs and cgi contribs

fixes #17
---
 contrib/cgi/spartan.go |  61 +++++++++++++++++++++
 contrib/fs/gemini.go   |   8 +--
 contrib/fs/spartan.go  | 145 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 210 insertions(+), 4 deletions(-)
 create mode 100644 contrib/cgi/spartan.go
 create mode 100644 contrib/fs/spartan.go

diff --git a/contrib/cgi/spartan.go b/contrib/cgi/spartan.go
new file mode 100644
index 0000000..36aaa36
--- /dev/null
+++ b/contrib/cgi/spartan.go
@@ -0,0 +1,61 @@
+package cgi
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	sr "tildegit.org/tjp/sliderule"
+	"tildegit.org/tjp/sliderule/logging"
+	"tildegit.org/tjp/sliderule/spartan"
+)
+
+// SpartanCGIDirectory runs any executable files relative to a root directory on the file system.
+//
+// It will also find and run any executables _part way_ through the path, so for example
+// a request for /foo/bar/baz can also run an executable found at /foo or  /foo/bar. In
+// such a case the PATH_INFO environment variable will include the remaining portion of
+// the URI path.
+func SpartanCGIDirectory(fsroot, urlroot, cmd string) sr.Handler {
+	fsroot = strings.TrimRight(fsroot, "/")
+	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
+		if !strings.HasPrefix(request.Path, urlroot) {
+			return nil
+		}
+
+		execpath, pathinfo, err := ResolveCGI(request.Path[len(urlroot):], fsroot)
+		if err != nil {
+			return spartan.ServerError(err)
+		}
+		if execpath == "" {
+			return nil
+		}
+		workdir := filepath.Dir(execpath)
+
+		if cmd != "" {
+			execpath = cmd
+		}
+
+		stderr := &bytes.Buffer{}
+		stdout, exitCode, err := RunCGI(ctx, request, execpath, pathinfo, workdir, stderr)
+		if err != nil {
+			return spartan.ServerError(err)
+		}
+		if exitCode != 0 {
+			ctx.Value("warnlog").(logging.Logger).Log(
+				"msg", "cgi exited with non-zero exit code",
+				"code", exitCode,
+				"stderr", stderr.String(),
+			)
+			return spartan.ServerError(fmt.Errorf("CGI process exited with status %d", exitCode))
+		}
+
+		response, err := spartan.ParseResponse(stdout)
+		if err != nil {
+			return spartan.ServerError(err)
+		}
+		return response
+	})
+}
diff --git a/contrib/fs/gemini.go b/contrib/fs/gemini.go
index 1fb4e20..79dcc63 100644
--- a/contrib/fs/gemini.go
+++ b/contrib/fs/gemini.go
@@ -127,17 +127,17 @@ func GeminiDirectoryDefault(fsroot, urlroot string, filenames ...string) sr.Hand
 	fsroot = strings.TrimRight(fsroot, "/")
 
 	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
+		if !strings.HasPrefix(request.Path, urlroot) {
+			return nil
+		}
+
 		if !strings.HasSuffix(request.Path, "/") {
 			u := *request.URL
 			u.Path += "/"
 			return gemini.PermanentRedirect(u.String())
 		}
 
-		if !strings.HasPrefix(request.Path, urlroot) {
-			return nil
-		}
 		requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/")
-
 		fpath := filepath.Join(fsroot, requestpath)
 		if isPrivate(fpath) {
 			return nil
diff --git a/contrib/fs/spartan.go b/contrib/fs/spartan.go
new file mode 100644
index 0000000..bee274a
--- /dev/null
+++ b/contrib/fs/spartan.go
@@ -0,0 +1,145 @@
+package fs
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	sr "tildegit.org/tjp/sliderule"
+	"tildegit.org/tjp/sliderule/spartan"
+)
+
+// SpartanFileHandler builds a handler which serves up files from a root directory.
+//
+// It only serves responses for paths which correspond to regular files or symlinks to them.
+func SpartanFileHandler(fsroot, urlroot string) sr.Handler {
+	fsroot = strings.TrimRight(fsroot, "/")
+
+	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
+		if !strings.HasPrefix(request.Path, urlroot) {
+			return nil
+		}
+		requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/")
+
+		fpath := filepath.Join(fsroot, requestpath)
+		if isPrivate(fpath) {
+			return nil
+		}
+		if isf, err := isFile(fpath); err != nil {
+			return spartan.ServerError(err)
+		} else if !isf {
+			return nil
+		}
+
+		file, err := os.Open(fpath)
+		if err != nil {
+			return spartan.ServerError(err)
+		}
+		return spartan.Success(mediaType(fpath), file)
+	})
+}
+
+// SpartanDirectoryDefault serves up default files for directory path requests.
+//
+// If any of the supported filenames are found, the contents of the file is returned as the
+// spartan response.
+//
+// It returns nil for any paths which don't correspond to a directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to the URL with the slash appended. This is necessary for relative links
+// in the directory's contents to function properly.
+func SpartanDirectoryDefault(fsroot, urlroot string, filenames ...string) sr.Handler {
+	fsroot = strings.TrimRight(fsroot, "/")
+
+	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
+		if !strings.HasPrefix(request.Path, urlroot) {
+			return nil
+		}
+
+		if !strings.HasSuffix(request.Path, "/") {
+			u := *request.URL
+			u.Path += "/"
+			return spartan.Redirect(u.String())
+		}
+
+		requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/")
+		fpath := filepath.Join(fsroot, requestpath)
+		if isPrivate(fpath) {
+			return nil
+		}
+		if isd, err := isDir(fpath); err != nil {
+			return spartan.ServerError(err)
+		} else if !isd {
+			return nil
+		}
+
+		for _, fname := range filenames {
+			candidatepath := filepath.Join(fpath, fname)
+			if isf, err := isFile(candidatepath); err != nil {
+				return spartan.ServerError(err)
+			} else if !isf {
+				continue
+			}
+
+			file, err := os.Open(candidatepath)
+			if err != nil {
+				return spartan.ServerError(err)
+			}
+			return spartan.Success(mediaType(candidatepath), file)
+		}
+
+		return nil
+	})
+}
+
+// SpartanDirectoryListing produces a listing of the contents of any requested directories.
+//
+// It returns a nil response for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links not the directory's contents to function properly.
+//
+// The template may be nil, in which case DefaultSpartanDirectoryList is used instead. The
+// template is then processed with RenderDirectoryListing.
+func SpartanDirectoryListing(fsroot, urlroot string, template *template.Template) sr.Handler {
+	fsroot = strings.TrimRight(fsroot, "/")
+
+	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
+		if !strings.HasSuffix(request.Path, "/") {
+			u := *request.URL
+			u.Path += "/"
+			return spartan.Redirect(u.String())
+		}
+		if !strings.HasPrefix(request.Path, urlroot) {
+			return nil
+		}
+		requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/")
+
+		fpath := filepath.Join(fsroot, requestpath)
+		if isPrivate(fpath) {
+			return nil
+		}
+		if isd, err := isDir(fpath); err != nil {
+			return spartan.ServerError(err)
+		} else if !isd {
+			return nil
+		}
+
+		if template == nil {
+			template = DefaultSpartanDirectoryList
+		}
+		body, err := RenderDirectoryListing(fpath, requestpath, template, request.Server)
+		if err != nil {
+			return spartan.ServerError(err)
+		}
+
+		return spartan.Success("text/gemini", body)
+	})
+}
+
+// DefaultSpartanDirectoryList is a tmeplate which renders a reasonable gemtext dir listing.
+var DefaultSpartanDirectoryList = DefaultGeminiDirectoryList
-- 
cgit v1.2.3