diff options
author | tjpcc <tjp@ctrl-c.club> | 2023-01-10 13:46:35 -0700 |
---|---|---|
committer | tjpcc <tjp@ctrl-c.club> | 2023-01-10 13:46:35 -0700 |
commit | 96f3a7607ffbdb349a4c2eff35efdf11b8d35a4e (patch) | |
tree | 8f1755bd3f3aedf33784f66aab9feccdd36c165e /contrib/cgi/cgi.go | |
parent | db7b6ef07254d61dee46a863786458e15a6459f6 (diff) |
Add a CGI contrib
Diffstat (limited to 'contrib/cgi/cgi.go')
-rw-r--r-- | contrib/cgi/cgi.go | 178 |
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[:]) +} |