summaryrefslogtreecommitdiff
path: root/contrib/cgi/cgi.go
diff options
context:
space:
mode:
authortjpcc <tjp@ctrl-c.club>2023-01-10 13:46:35 -0700
committertjpcc <tjp@ctrl-c.club>2023-01-10 13:46:35 -0700
commit96f3a7607ffbdb349a4c2eff35efdf11b8d35a4e (patch)
tree8f1755bd3f3aedf33784f66aab9feccdd36c165e /contrib/cgi/cgi.go
parentdb7b6ef07254d61dee46a863786458e15a6459f6 (diff)
Add a CGI contrib
Diffstat (limited to 'contrib/cgi/cgi.go')
-rw-r--r--contrib/cgi/cgi.go178
1 files changed, 178 insertions, 0 deletions
diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go
new file mode 100644
index 0000000..2e20485
--- /dev/null
+++ b/contrib/cgi/cgi.go
@@ -0,0 +1,178 @@
+package cgi
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io/fs"
+ "net"
+ "os"
+ "os/exec"
+ "strings"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func CGIHandler(pathPrefix, rootDir string) gemini.Handler {
+ rootDir = strings.TrimRight(rootDir, "/")
+
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ if !strings.HasPrefix(req.Path, pathPrefix) {
+ return gemini.NotFound("Resource does not exist.")
+ }
+
+ path := req.Path[len(pathPrefix):]
+ segments := strings.Split(strings.TrimLeft(path, "/"), "/")
+ for i := range append(segments, "") {
+ path := strings.Join(append([]string{rootDir}, segments[:i]...), "/")
+ path = strings.TrimRight(path, "/")
+ isDir, isExecutable, err := executableFile(path)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if isExecutable {
+ pathInfo := ""
+ if len(segments) > i+1 {
+ pathInfo = strings.Join(segments[i+1:], "/")
+ }
+ return runCGI(ctx, req.Server, req, path, pathInfo)
+ }
+
+ if !isDir {
+ break
+ }
+ }
+
+ return gemini.NotFound("Resource does not exist.")
+ }
+}
+
+func executableFile(path string) (bool, bool, error) {
+ file, err := os.Open(path)
+ if isNotExistError(err) {
+ return false, false, nil
+ }
+ if err != nil {
+ return false, false, err
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return false, false, err
+ }
+
+ if info.IsDir() {
+ return true, false, nil
+ }
+
+ // readable + executable by anyone
+ return false, info.Mode()&0005 == 0005, nil
+}
+
+func isNotExistError(err error) bool {
+ if err != nil {
+ var pathErr *fs.PathError
+ if errors.As(err, &pathErr) {
+ e := pathErr.Err
+ if errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func runCGI(
+ ctx context.Context,
+ server *gemini.Server,
+ req *gemini.Request,
+ filePath string,
+ pathInfo string,
+) *gemini.Response {
+ pathSegments := strings.Split(filePath, "/")
+
+ dirPath := "."
+ if len(pathSegments) > 1 {
+ dirPath = strings.Join(pathSegments[:len(pathSegments)-1], "/")
+ }
+ filePath = "./" + pathSegments[len(pathSegments)-1]
+
+ cmd := exec.CommandContext(ctx, filePath)
+ cmd.Env = prepareCGIEnv(ctx, server, req, filePath, pathInfo)
+ cmd.Dir = dirPath
+
+ responseBuffer := &bytes.Buffer{}
+ cmd.Stdout = responseBuffer
+
+ if err := cmd.Run(); err != nil {
+ var exErr *exec.ExitError
+ if errors.As(err, &exErr) {
+ return gemini.CGIError(fmt.Sprintf("CGI returned with exit code %d", exErr.ExitCode()))
+ }
+ return gemini.Failure(err)
+ }
+
+ response, err := gemini.ParseResponse(responseBuffer)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+ return response
+}
+
+func prepareCGIEnv(
+ ctx context.Context,
+ server *gemini.Server,
+ req *gemini.Request,
+ scriptName string,
+ pathInfo string,
+) []string {
+ var authType string
+ if len(req.TLSState.PeerCertificates) > 0 {
+ authType = "Certificate"
+ }
+
+ environ := []string{
+ "AUTH_TYPE=" + authType,
+ "CONTENT_LENGTH=",
+ "CONTENT_TYPE=",
+ "GATEWAY_INTERFACE=CGI/1.1",
+ "PATH_INFO=" + pathInfo,
+ "PATH_TRANSLATED=",
+ "QUERY_STRING=" + req.RawQuery,
+ }
+
+ host, _, _ := net.SplitHostPort(req.RemoteAddr.String())
+ environ = append(environ, "REMOTE_ADDR="+host)
+
+ environ = append(
+ environ,
+ "REMOTE_HOST=",
+ "REMOTE_IDENT=",
+ "SCRIPT_NAME="+scriptName,
+ "SERVER_NAME="+server.Hostname(),
+ "SERVER_PORT="+server.Port(),
+ "SERVER_PROTOCOL=GEMINI",
+ "SERVER_SOFTWARE=GUS",
+ )
+
+ if len(req.TLSState.PeerCertificates) > 0 {
+ cert := req.TLSState.PeerCertificates[0]
+ environ = append(
+ environ,
+ "TLS_CLIENT_HASH="+fingerprint(cert.Raw),
+ )
+ }
+
+ return environ
+}
+
+func fingerprint(raw []byte) string {
+ hash := sha256.Sum256(raw)
+ return hex.EncodeToString(hash[:])
+}