From 38ff3807b3b97da22006b5bdcf03fdfaaa4b0582 Mon Sep 17 00:00:00 2001 From: tjpcc Date: Thu, 7 Sep 2023 12:36:17 -0600 Subject: all the gopher CGI handlers to support gophernicus behaviors --- contrib/cgi/cgi.go | 16 ++--- contrib/cgi/gopher.go | 139 +++++++++++++++++++++++++++++++++++--- contrib/fs/gopher.go | 183 +++++++++++++++++++++++++++++++------------------- 3 files changed, 248 insertions(+), 90 deletions(-) (limited to 'contrib') diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go index bcdd5e1..749a284 100644 --- a/contrib/cgi/cgi.go +++ b/contrib/cgi/cgi.go @@ -25,11 +25,11 @@ import ( // a request for /foo/bar/baz can run an executable found at /foo or /foo/bar. In such // a case the PATH_INFO would include the remaining portion of the URI path. func ResolveCGI(requestPath, fsRoot string) (string, string, error) { + fsRoot = strings.TrimRight(fsRoot, "/") segments := strings.Split(strings.TrimLeft(requestPath, "/"), "/") for i := range append(segments, "") { filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/") - filepath = strings.TrimRight(filepath, "/") isDir, isExecutable, err := executableFile(filepath) if err != nil { return "", "", err @@ -52,26 +52,20 @@ func ResolveCGI(requestPath, fsRoot string) (string, string, error) { } func executableFile(filepath string) (bool, bool, error) { - file, err := os.Open(filepath) + info, err := os.Stat(filepath) 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 + return false, info.Mode()&5 == 5, nil } func isNotExistError(err error) bool { @@ -94,7 +88,7 @@ func RunCGI( request *sr.Request, executable string, pathInfo string, -) (io.Reader, int, error) { +) (*bytes.Buffer, int, error) { pathSegments := strings.Split(executable, "/") dirPath := "." @@ -105,7 +99,7 @@ func RunCGI( infoLen := len(pathInfo) if pathInfo == "/" { - infoLen -= 1 + infoLen = 0 } scriptName := request.Path[:len(request.Path)-infoLen] diff --git a/contrib/cgi/gopher.go b/contrib/cgi/gopher.go index 290adfa..98a3c75 100644 --- a/contrib/cgi/gopher.go +++ b/contrib/cgi/gopher.go @@ -3,10 +3,14 @@ package cgi import ( "context" "fmt" + "os" + "path" + "path/filepath" "strings" sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/gopher/gophermap" ) // GopherCGIDirectory runs any executable files relative to a root directory on the file system. @@ -15,31 +19,148 @@ import ( // 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 GopherCGIDirectory(pathRoot, fsRoot string) sr.Handler { +func GopherCGIDirectory(pathRoot, fsRoot string, settings *gophermap.FileSystemSettings) sr.Handler { + if settings == nil { + settings = &gophermap.FileSystemSettings{} + } + + if !settings.Exec { + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { return nil }) + } + fsRoot = strings.TrimRight(fsRoot, "/") return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { if !strings.HasPrefix(request.Path, pathRoot) { return nil } - filepath, pathinfo, err := ResolveCGI(request.Path[len(pathRoot):], fsRoot) + fullpath, pathinfo, err := resolveGopherCGI(fsRoot, request) if err != nil { return gopher.Error(err).Response() } - if filepath == "" { + if fullpath == "" { + return nil + } + + return runGopherCGI(ctx, request, fullpath, pathinfo, *settings) + }) +} + +// ExecGopherMaps runs any gophermaps +func ExecGopherMaps(pathRoot, fsRoot string, settings *gophermap.FileSystemSettings) sr.Handler { + if settings == nil { + settings = &gophermap.FileSystemSettings{} + } + + if !settings.Exec { + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { return nil }) + } + + fsRoot = strings.TrimRight(fsRoot, "/") + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { + if !strings.HasPrefix(request.Path, pathRoot) { return nil } - stdout, exitCode, err := RunCGI(ctx, request, filepath, pathinfo) + fullpath := filepath.Join(fsRoot, strings.Trim(request.Path, "/")) + info, err := os.Stat(fullpath) + if isNotExistError(err) { + return nil + } if err != nil { return gopher.Error(err).Response() } - if exitCode != 0 { - return gopher.Error( - fmt.Errorf("CGI process exited with status %d", exitCode), - ).Response() + + if info.IsDir() { + for _, fname := range settings.DirMaps { + fpath := filepath.Join(fullpath, fname) + finfo, err := os.Stat(fpath) + if isNotExistError(err) { + continue + } + if err != nil { + return gopher.Error(err).Response() + } + + m := finfo.Mode() + if m.IsDir() { + continue + } + if !m.IsRegular() || m&5 != 5 { + continue + } + return runGopherCGI(ctx, request, fpath, "/", *settings) + } + + return nil + } + + m := info.Mode() + if !m.IsRegular() || m&5 != 5 { + return nil } - return gopher.File(0, stdout) + return runGopherCGI(ctx, request, fullpath, "/", *settings) }) } + +func runGopherCGI( + ctx context.Context, + request *sr.Request, + fullpath string, + pathinfo string, + settings gophermap.FileSystemSettings, +) *sr.Response { + stdout, exitCode, err := RunCGI(ctx, request, fullpath, pathinfo) + if err != nil { + return gopher.Error(err).Response() + } + if exitCode != 0 { + return gopher.Error( + fmt.Errorf("CGI process exited with status %d", exitCode), + ).Response() + } + + if settings.ParseExtended { + edoc, err := gophermap.ParseExtended(stdout, request.URL) + if err != nil { + return gopher.Error(err).Response() + } + + doc, _, err := edoc.Compatible(filepath.Dir(fullpath), settings) + return doc.Response() + } + + return gopher.File(gopher.MenuType, stdout) +} + +func resolveGopherCGI(fsRoot string, request *sr.Request) (string, string, error) { + reqPath := strings.TrimLeft(request.Path, "/") + + segments := append([]string{""}, strings.Split(reqPath, "/")...) + fullpath := fsRoot + for i, segment := range segments { + fullpath = filepath.Join(fullpath, segment) + + info, err := os.Stat(fullpath) + if isNotExistError(err) { + return "", "", nil + } + if err != nil { + return "", "", err + } + + if !info.IsDir() { + if info.Mode()&5 == 5 { + pathinfo := "/" + if len(segments) > i+1 { + pathinfo = path.Join(segments[i:]...) + } + return fullpath, pathinfo, nil + } + break + } + } + + return "", "", nil +} diff --git a/contrib/fs/gopher.go b/contrib/fs/gopher.go index 0594730..4d86ba6 100644 --- a/contrib/fs/gopher.go +++ b/contrib/fs/gopher.go @@ -2,29 +2,56 @@ package fs import ( "context" - "io/fs" + "os" + "path/filepath" + "slices" "strings" - "text/template" sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/gopher/gophermap" ) // GopherFileHandler builds a handler which serves up files from a file system. // // It only serves responses for paths which correspond to files, not directories. -func GopherFileHandler(fileSystem fs.FS) sr.Handler { +func GopherFileHandler(rootpath string, settings *gophermap.FileSystemSettings) sr.Handler { return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { - filepath, file, err := ResolveFile(request, fileSystem) + path := filepath.Join(rootpath, strings.Trim(request.Path, "/")) + if isPrivate(path) { + return nil + } + if isf, err := isFile(path); err != nil { + return gopher.Error(err).Response() + } else if !isf { + return nil + } + + if settings == nil { + settings = &gophermap.FileSystemSettings{} + } + + file, err := os.Open(path) if err != nil { return gopher.Error(err).Response() } - if file == nil { - return nil + if !(settings.ParseExtended && isMap(path, *settings)) { + return gopher.File(gopher.GuessItemType(path), file) + } + + defer func() { _ = file.Close() }() + + edoc, err := gophermap.ParseExtended(file, request.URL) + if err != nil { + return gopher.Error(err).Response() } - return gopher.File(gopher.GuessItemType(filepath), file) + doc, _, err := edoc.Compatible(filepath.Dir(path), *settings) + if err != nil { + return gopher.Error(err).Response() + } + return doc.Response() }) } @@ -34,95 +61,111 @@ func GopherFileHandler(fileSystem fs.FS) sr.Handler { // contents of that file is returned as the gopher response. // // It returns nil for any paths which don't correspond to a directory. -// -// It requires that files from the provided fs.FS implement fs.ReadDirFile. If -// they don't, it will produce nil responses for all directory paths. -func GopherDirectoryDefault(fileSystem fs.FS, filenames ...string) sr.Handler { +func GopherDirectoryDefault(rootpath string, settings *gophermap.FileSystemSettings) sr.Handler { return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { - dirpath, dir, err := ResolveDirectory(request, fileSystem) - if err != nil { - return gopher.Error(err).Response() + path := filepath.Join(rootpath, strings.Trim(request.Path, "/")) + if isPrivate(path) { + return nil } - if dir == nil { + if isd, err := isDir(path); err != nil { + return gopher.Error(err).Response() + } else if !isd { return nil } - defer func() { _ = dir.Close() }() - _, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames) - if err != nil { - return gopher.Error(err).Response() + if settings == nil { + settings = &gophermap.FileSystemSettings{} } - if file == nil { - return nil + + for _, fname := range settings.DirMaps { + fpath := filepath.Join(path, fname) + if isf, err := isFile(fpath); err != nil { + return gopher.Error(err).Response() + } else if !isf { + continue + } + + file, err := os.Open(fpath) + if err != nil { + return gopher.Error(err).Response() + } + + if settings.ParseExtended { + defer func() { _ = file.Close() }() + + edoc, err := gophermap.ParseExtended(file, request.URL) + if err != nil { + return gopher.Error(err).Response() + } + + doc, _, err := edoc.Compatible(path, *settings) + if err != nil { + return gopher.Error(err).Response() + } + return doc.Response() + } else { + return gopher.File(gopher.MenuType, file) + } } - return gopher.File(gopher.MenuType, file) + return nil }) } // GopherDirectoryListing produces a listing of the contents of any requested directories. // // It returns nil for any paths which don't correspond to a filesystem directory. -// -// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they -// don't, it will produce nil responses for any directory paths. -// -// A template may be nil, in which case DefaultGopherDirectoryList is used instead. The -// template is then processed with RenderDirectoryListing. -func GopherDirectoryListing(fileSystem fs.FS, tpl *template.Template) sr.Handler { +func GopherDirectoryListing(rootpath string, settings *gophermap.FileSystemSettings) sr.Handler { return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { - dirpath, dir, err := ResolveDirectory(request, fileSystem) - if err != nil { - return gopher.Error(err).Response() + path := filepath.Join(rootpath, strings.Trim(request.Path, "/")) + if isPrivate(path) { + return nil } - if dir == nil { + if isd, err := isDir(path); err != nil { + return gopher.Error(err).Response() + } else if !isd { return nil } - defer func() { _ = dir.Close() }() - if tpl == nil { - tpl = DefaultGopherDirectoryList + if settings == nil { + settings = &gophermap.FileSystemSettings{} } - body, err := RenderDirectoryListing(dirpath, dir, tpl, request.Server) + doc, err := gophermap.ListDir(path, request.URL, *settings) if err != nil { return gopher.Error(err).Response() } - return gopher.File(gopher.MenuType, body) + return doc.Response() }) } -// GopherTemplateFunctions is a map for templates providing useful functions for gophermaps. -// -// - GuessItemType: return a gopher item type for a file based on its path/name. -var GopherTemplateFunctions = template.FuncMap{ - "GuessItemType": func(filepath string) string { - return string([]byte{byte(gopher.GuessItemType(filepath))}) - }, +func isDir(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if isNotFound(err) { + err = nil + } + return false, err + } + return info.IsDir() && info.Mode()&4 == 4, nil } -// DefaultGopherDirectoryList is a template which renders a directory listing as gophermap. -var DefaultGopherDirectoryList = template.Must( - template.New("gopher_dirlist").Funcs(GopherTemplateFunctions).Parse( - strings.ReplaceAll( - ` -{{ $root := .FullPath -}} -{{ if eq .FullPath "." }}{{ $root = "" }}{{ end -}} -{{ $hostname := .Hostname -}} -{{ $port := .Port -}} -i{{ .DirName }} {{ $hostname }} {{ $port }} -i {{ $hostname }} {{ $port }} -{{ range .Entries -}} -{{ if .IsDir -}} -1{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }} -{{- else -}} -{{ GuessItemType .Name }}{{ .Name }} {{ $root }}/{{ .Name }} {{ $hostname }} {{ $port }} -{{- end }} -{{ end -}} -. -`[1:], - "\n", - "\r\n", - ), - ), -) +func isFile(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if isNotFound(err) { + err = nil + } + return false, err + } + m := info.Mode() + + return m.IsRegular() && m&4 == 4, nil +} + +func isMap(path string, settings gophermap.FileSystemSettings) bool { + if strings.HasSuffix(path, ".gophermap") { + return true + } + return slices.Contains(settings.DirMaps, filepath.Base(path)) +} -- cgit v1.2.3