diff options
-rw-r--r-- | contrib/cgi/cgi.go | 50 | ||||
-rw-r--r-- | contrib/cgi/gemini.go | 51 | ||||
-rw-r--r-- | contrib/cgi/gopher.go | 155 | ||||
-rw-r--r-- | contrib/cgi/handlers.go | 57 | ||||
-rw-r--r-- | contrib/cgi/spartan.go | 51 | ||||
-rw-r--r-- | contrib/fs/file.go | 52 | ||||
-rw-r--r-- | contrib/fs/gemini.go | 109 | ||||
-rw-r--r-- | contrib/fs/gopher.go | 150 | ||||
-rw-r--r-- | contrib/fs/handlers.go | 162 | ||||
-rw-r--r-- | contrib/fs/spartan.go | 110 | ||||
-rw-r--r-- | gemini/protocol.go | 28 | ||||
-rw-r--r-- | gopher/gophermap/extended.go | 32 | ||||
-rw-r--r-- | gopher/protocol.go | 27 | ||||
-rw-r--r-- | gopher/response.go | 30 | ||||
-rw-r--r-- | internal/filetypes.go | 57 | ||||
-rw-r--r-- | internal/types/protocol.go | 19 | ||||
-rw-r--r-- | server.go | 1 | ||||
-rw-r--r-- | spartan/protocol.go | 26 |
18 files changed, 466 insertions, 701 deletions
diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go index b7dd14a..1b5bfcc 100644 --- a/contrib/cgi/cgi.go +++ b/contrib/cgi/cgi.go @@ -11,6 +11,7 @@ import ( "net" "os" "os/exec" + "path" "path/filepath" "strings" @@ -25,48 +26,37 @@ import ( // It will find executables which are just part way through the path, so for example // 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, "/"), "/") +func ResolveCGI(requestpath, fsroot string) (string, string, error) { + segments := append([]string{""}, strings.Split(requestpath, "/")...) - for i := range append(segments, "") { - filepath := strings.Join(append([]string{fsRoot}, segments[:i]...), "/") - isDir, isExecutable, err := executableFile(filepath) + fullpath := fsroot + for i, segment := range segments { + fullpath = filepath.Join(fullpath, segment) + + info, err := os.Stat(fullpath) + if isNotExistError(err) { + break + } if err != nil { return "", "", err } - if isExecutable { - pathinfo := "/" - if len(segments) > i+1 { - pathinfo = strings.Join(segments[i:], "/") - } - return filepath, pathinfo, nil + if info.IsDir() { + continue } - if !isDir { + if info.Mode()&5 != 5 { break } - } - - return "", "", nil -} -func executableFile(filepath string) (bool, bool, error) { - info, err := os.Stat(filepath) - if isNotExistError(err) { - return false, false, nil - } - if err != nil { - return false, false, err - } - - if info.IsDir() { - return true, false, nil + pathinfo := "/" + if len(segments) > i+1 { + pathinfo = path.Join(segments[i:]...) + } + return fullpath, pathinfo, nil } - // readable + executable by anyone - return false, info.Mode()&5 == 5, nil + return "", "", nil } func isNotExistError(err error) bool { diff --git a/contrib/cgi/gemini.go b/contrib/cgi/gemini.go index 0aa3044..9e4d68f 100644 --- a/contrib/cgi/gemini.go +++ b/contrib/cgi/gemini.go @@ -1,15 +1,8 @@ package cgi import ( - "bytes" - "context" - "fmt" - "path/filepath" - "strings" - - sr "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" - "tildegit.org/tjp/sliderule/logging" ) // GeminiCGIDirectory runs any executable files relative to a root directory on the file system. @@ -18,44 +11,6 @@ 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 GeminiCGIDirectory(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 gemini.Failure(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 gemini.Failure(err) - } - if exitCode != 0 { - ctx.Value("warnlog").(logging.Logger).Log( - "msg", "cgi exited with non-zero exit code", - "code", exitCode, - "stderr", stderr.String(), - ) - return gemini.CGIError(fmt.Sprintf("CGI process exited with status %d", exitCode)) - } - - response, err := gemini.ParseResponse(stdout) - if err != nil { - return gemini.Failure(err) - } - return response - }) +func GeminiCGIDirectory(fsroot, urlroot, cmd string) sliderule.Handler { + return cgiDirectory(gemini.ServerProtocol, fsroot, urlroot, cmd) } diff --git a/contrib/cgi/gopher.go b/contrib/cgi/gopher.go index 7067a6d..8704904 100644 --- a/contrib/cgi/gopher.go +++ b/contrib/cgi/gopher.go @@ -1,18 +1,11 @@ package cgi import ( - "bytes" "context" - "fmt" - "os" - "path" - "path/filepath" - "strings" sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" "tildegit.org/tjp/sliderule/gopher/gophermap" - "tildegit.org/tjp/sliderule/logging" ) // GopherCGIDirectory runs any executable files relative to a root directory on the file system. @@ -25,151 +18,7 @@ func GopherCGIDirectory(fsroot, urlroot, cmd string, settings *gophermap.FileSys if settings == nil || !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, urlroot) { - return nil - } - requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/") - - fullpath, pathinfo, err := resolveGopherCGI(fsroot, requestpath) - if err != nil { - return gopher.Error(err).Response() - } - if fullpath == "" { - return nil - } - - return runGopherCGI(ctx, request, fullpath, pathinfo, cmd, *settings) - }) -} - -// ExecGopherMaps runs any gophermaps -func ExecGopherMaps(fsroot, urlroot, cmd string, settings *gophermap.FileSystemSettings) sr.Handler { - if settings == nil || !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, urlroot) { - return nil - } - requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/") - - fullpath := filepath.Join(fsroot, requestpath) - info, err := os.Stat(fullpath) - if isNotExistError(err) { - return nil - } - if err != nil { - return gopher.Error(err).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, "/", cmd, *settings) - } - - return nil - } - - m := info.Mode() - if !m.IsRegular() || m&5 != 5 { - return nil - } - - return runGopherCGI(ctx, request, fullpath, "/", cmd, *settings) - }) -} - -func runGopherCGI( - ctx context.Context, - request *sr.Request, - fullpath string, - pathinfo string, - cmd string, - settings gophermap.FileSystemSettings, -) *sr.Response { - workdir := filepath.Dir(fullpath) - if cmd != "" { - fullpath = cmd - } - - stderr := &bytes.Buffer{} - stdout, exitCode, err := RunCGI(ctx, request, fullpath, pathinfo, workdir, stderr) - if err != nil { - return gopher.Error(err).Response() - } - if exitCode != 0 { - ctx.Value("warnlog").(logging.Logger).Log( - "msg", "cgi exited with non-zero exit code", - "code", exitCode, - "stderr", stderr.String(), - ) - 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) - if err != nil { - return gopher.Error(err).Response() - } - return doc.Response() - } - - return gopher.File(gopher.MenuType, stdout) -} - -func resolveGopherCGI(fsRoot string, reqPath string) (string, string, error) { - 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 + handler := cgiDirectory(gopher.ServerProtocol, fsroot, urlroot, cmd) + return gophermap.ExtendMiddleware(fsroot, urlroot, settings)(handler) } diff --git a/contrib/cgi/handlers.go b/contrib/cgi/handlers.go new file mode 100644 index 0000000..03a1db7 --- /dev/null +++ b/contrib/cgi/handlers.go @@ -0,0 +1,57 @@ +package cgi + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "strings" + + sr "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/logging" +) + +func cgiDirectory(protocol sr.ServerProtocol, 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 + } + + rpath := strings.TrimPrefix(request.Path, urlroot) + rpath = strings.Trim(rpath, "/") + execpath, pathinfo, err := ResolveCGI(rpath, fsroot) + if err != nil { + return protocol.TemporaryServerError(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 protocol.TemporaryServerError(err) + } + if exitCode != 0 { + _ = ctx.Value("warnlog").(logging.Logger).Log( + "msg", "cgi exited with non-zero exit code", + "code", exitCode, + "stderr", stderr.String(), + ) + return protocol.CGIFailure(fmt.Errorf("CGI process exited with status %d", exitCode)) + } + + response, err := protocol.ParseResponse(stdout) + if err != nil { + return protocol.TemporaryServerError(err) + } + return response + }) +} diff --git a/contrib/cgi/spartan.go b/contrib/cgi/spartan.go index 36aaa36..32ea66c 100644 --- a/contrib/cgi/spartan.go +++ b/contrib/cgi/spartan.go @@ -1,14 +1,7 @@ package cgi import ( - "bytes" - "context" - "fmt" - "path/filepath" - "strings" - - sr "tildegit.org/tjp/sliderule" - "tildegit.org/tjp/sliderule/logging" + "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/spartan" ) @@ -18,44 +11,6 @@ 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 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 - }) +func SpartanCGIDirectory(fsroot, urlroot, cmd string) sliderule.Handler { + return cgiDirectory(spartan.ServerProtocol, fsroot, urlroot, cmd) } diff --git a/contrib/fs/file.go b/contrib/fs/file.go index 9f11f4f..4d79fea 100644 --- a/contrib/fs/file.go +++ b/contrib/fs/file.go @@ -1,36 +1,9 @@ package fs import ( - "mime" - "os" "strings" - "unicode/utf8" ) -func mediaType(filePath string) string { - if strings.HasSuffix(filePath, ".gmi") { - // This may not be present in the listings searched by mime.TypeByExtension, - // so provide a dedicated fast path for it here. - return "text/gemini" - } - - slashIdx := strings.LastIndex(filePath, "/") - dotIdx := strings.LastIndex(filePath[slashIdx+1:], ".") - if dotIdx == -1 { - return "application/octet-stream" - } - ext := filePath[slashIdx+1+dotIdx:] - - mtype := mime.TypeByExtension(ext) - if mtype == "" { - if contentsAreText(filePath) { - return "text/plain" - } - return "application/octet-stream" - } - return mtype -} - func isPrivate(fullpath string) bool { for _, segment := range strings.Split(fullpath, "/") { if len(segment) > 1 && segment[0] == '.' { @@ -39,28 +12,3 @@ func isPrivate(fullpath string) bool { } return false } - -func contentsAreText(filepath string) bool { - f, err := os.Open(filepath) - if err != nil { - return false - } - defer func() { _ = f.Close() }() - - var buf [1024]byte - n, err := f.Read(buf[:]) - if err != nil { - return false - } - - for i, c := range string(buf[:n]) { - if i+utf8.UTFMax > n { - // incomplete last char - break - } - if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' { - return false - } - } - return true -} diff --git a/contrib/fs/gemini.go b/contrib/fs/gemini.go index 79dcc63..6f9c75d 100644 --- a/contrib/fs/gemini.go +++ b/contrib/fs/gemini.go @@ -7,7 +7,6 @@ import ( "net/url" "os" "path" - "path/filepath" "strings" "text/template" @@ -42,7 +41,7 @@ func TitanUpload(fsroot, urlroot string, approver tlsauth.Approver) sr.Middlewar if _, err := io.Copy(tmpf, body); err != nil { _ = os.Remove(tmpf.Name()) - return gemini.PermanentFailure(err) + return gemini.Failure(err) } request = cloneRequest(request) @@ -87,30 +86,7 @@ func cloneRequest(start *sr.Request) *sr.Request { // // It only serves responses for paths which do not correspond to directories on disk. func GeminiFileHandler(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 gemini.Failure(err) - } else if !isf { - return nil - } - - file, err := os.Open(fpath) - if err != nil { - return gemini.Failure(err) - } - return gemini.Success(mediaType(fpath), file) - }) + return fileHandler(gemini.ServerProtocol, fsroot, urlroot) } // GeminiDirectoryDefault serves up default files for directory path requests. @@ -124,47 +100,7 @@ func GeminiFileHandler(fsroot, urlroot string) sr.Handler { // redirects to a URL with the trailing slash appended. This is necessary for relative // links in the directory's contents to function properly. func GeminiDirectoryDefault(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 gemini.PermanentRedirect(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 gemini.Failure(err) - } else if !isd { - return nil - } - - for _, fname := range filenames { - candidatepath := filepath.Join(fpath, fname) - if isf, err := isFile(candidatepath); err != nil { - return gemini.Failure(err) - } else if !isf { - continue - } - - file, err := os.Open(candidatepath) - if err != nil { - return gemini.Failure(err) - } - return gemini.Success(mediaType(candidatepath), file) - } - - return nil - }) + return directoryDefault(gemini.ServerProtocol, fsroot, urlroot, true, filenames...) } // GeminiDirectoryListing produces a listing of the contents of any requested directories. @@ -177,40 +113,11 @@ func GeminiDirectoryDefault(fsroot, urlroot string, filenames ...string) sr.Hand // // The template may be nil, in which case DefaultGeminiDirectoryList is used instead. The // template is then processed with RenderDirectoryListing. -func GeminiDirectoryListing(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 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 - } - if isd, err := isDir(fpath); err != nil { - return gemini.Failure(err) - } else if !isd { - return nil - } - - if template == nil { - template = DefaultGeminiDirectoryList - } - body, err := RenderDirectoryListing(fpath, requestpath, template, request.Server) - if err != nil { - return gemini.Failure(err) - } - - return gemini.Success("text/gemini", body) - }) +func GeminiDirectoryListing(fsroot, urlroot string, tmpl *template.Template) sr.Handler { + if tmpl == nil { + tmpl = DefaultGeminiDirectoryList + } + return directoryListing(gemini.ServerProtocol, fsroot, urlroot, "file.gmi", true, tmpl) } // DefaultGeminiDirectoryList is a template which renders a reasonable gemtext dir list. diff --git a/contrib/fs/gopher.go b/contrib/fs/gopher.go index 0a0b482..209a4ec 100644 --- a/contrib/fs/gopher.go +++ b/contrib/fs/gopher.go @@ -2,9 +2,6 @@ package fs import ( "context" - "os" - "path/filepath" - "slices" "strings" sr "tildegit.org/tjp/sliderule" @@ -16,50 +13,8 @@ import ( // // It only serves responses for paths which correspond to files, not directories. func GopherFileHandler(fsroot, urlroot string, settings *gophermap.FileSystemSettings) 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), "/") - - path := filepath.Join(fsroot, requestpath) - 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 !(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() - } - - doc, _, err := edoc.Compatible(filepath.Dir(path), *settings) - if err != nil { - return gopher.Error(err).Response() - } - return doc.Response() - }) + handler := fileHandler(gopher.ServerProtocol, fsroot, urlroot) + return gophermap.ExtendMiddleware(fsroot, urlroot, settings)(handler) } // GopherDirectoryDefault serves up default files for directory path requests. @@ -69,61 +24,12 @@ func GopherFileHandler(fsroot, urlroot string, settings *gophermap.FileSystemSet // // It returns nil for any paths which don't correspond to a directory. func GopherDirectoryDefault(fsroot, urlroot string, settings *gophermap.FileSystemSettings) 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), "/") - - path := filepath.Join(fsroot, requestpath) - if isPrivate(path) { - return nil - } - if isd, err := isDir(path); err != nil { - return gopher.Error(err).Response() - } else if !isd { - return nil - } - - if settings == nil { - settings = &gophermap.FileSystemSettings{} - } - - 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) - } - } + if settings == nil { + return sr.HandlerFunc(func(_ context.Context, _ *sr.Request) *sr.Response { return nil }) + } - return nil - }) + handler := directoryDefault(gopher.ServerProtocol, fsroot, urlroot, false, settings.DirMaps...) + return gophermap.ExtendMiddleware(fsroot, urlroot, settings)(handler) } // GopherDirectoryListing produces a listing of the contents of any requested directories. @@ -136,13 +42,13 @@ func GopherDirectoryListing(fsroot, urlroot string, settings *gophermap.FileSyst if !strings.HasPrefix(request.Path, urlroot) { return nil } - requestpath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/") - path := filepath.Join(fsroot, requestpath) - if isPrivate(path) { + dpath, _ := rebasePath(fsroot, urlroot, request) + + if isPrivate(dpath) { return nil } - if isd, err := isDir(path); err != nil { + if isd, err := isDir(dpath); err != nil { return gopher.Error(err).Response() } else if !isd { return nil @@ -151,7 +57,7 @@ func GopherDirectoryListing(fsroot, urlroot string, settings *gophermap.FileSyst if settings == nil { settings = &gophermap.FileSystemSettings{} } - doc, err := gophermap.ListDir(path, request.URL, *settings) + doc, err := gophermap.ListDir(dpath, request.URL, *settings) if err != nil { return gopher.Error(err).Response() } @@ -159,35 +65,3 @@ func GopherDirectoryListing(fsroot, urlroot string, settings *gophermap.FileSyst return doc.Response() }) } - -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 -} - -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 { - base := filepath.Base(path) - if base == "gophermap" || strings.HasSuffix(base, ".gph") || strings.HasSuffix(base, ".gophermap") { - return true - } - return slices.Contains(settings.DirMaps, filepath.Base(path)) -} diff --git a/contrib/fs/handlers.go b/contrib/fs/handlers.go new file mode 100644 index 0000000..75422d9 --- /dev/null +++ b/contrib/fs/handlers.go @@ -0,0 +1,162 @@ +package fs + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "text/template" + + sr "tildegit.org/tjp/sliderule" +) + +func fileHandler(protocol sr.ServerProtocol, 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 + } + + fpath, _ := rebasePath(fsroot, urlroot, request) + + if isPrivate(fpath) { + return nil + } + if isf, err := isFile(fpath); err != nil { + return protocol.TemporaryServerError(err) + } else if !isf { + return nil + } + + file, err := os.Open(fpath) + if err != nil { + return protocol.TemporaryServerError(err) + } + return protocol.Success(filepath.Base(fpath), file) + }) +} + +func directoryDefault( + protocol sr.ServerProtocol, + fsroot string, + urlroot string, + redirectSlash bool, + 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 + } + + fpath, _ := rebasePath(fsroot, urlroot, request) + + if isPrivate(fpath) { + return nil + } + if isd, err := isDir(fpath); err != nil { + return protocol.TemporaryServerError(err) + } else if !isd { + return nil + } + + if redirectSlash && !strings.HasSuffix(request.Path, "/") { + return protocol.PermanentRedirect(appendSlash(request.URL)) + } + + for _, fname := range filenames { + fpath := filepath.Join(fpath, fname) + if isf, err := isFile(fpath); err != nil { + return protocol.TemporaryServerError(err) + } else if !isf { + continue + } + + file, err := os.Open(fpath) + if err != nil { + return protocol.TemporaryServerError(err) + } + return protocol.Success(filepath.Base(fpath), file) + } + + return nil + }) +} + +func directoryListing( + protocol sr.ServerProtocol, + fsroot string, + urlroot string, + successFilename string, + redirectSlash bool, + tmpl *template.Template, +) 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 + } + + dpath, rpath := rebasePath(fsroot, urlroot, request) + + if isPrivate(dpath) { + return nil + } + if isd, err := isDir(dpath); err != nil { + return protocol.TemporaryServerError(err) + } else if !isd { + return nil + } + + if redirectSlash && !strings.HasSuffix(request.Path, "/") { + return protocol.PermanentRedirect(appendSlash(request.URL)) + } + + body, err := RenderDirectoryListing(dpath, rpath, tmpl, request.Server) + if err != nil { + return protocol.TemporaryServerError(err) + } + + return protocol.Success(successFilename, body) + }) +} + +func rebasePath(fsroot, urlroot string, request *sr.Request) (string, string) { + p := strings.TrimPrefix(request.Path, urlroot) + p = strings.Trim(p, "/") + return filepath.Join(fsroot, p), p +} + +func appendSlash(u *url.URL) *url.URL { + v := *u + v.Path += "/" + return &v +} + +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 +} + +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 +} diff --git a/contrib/fs/spartan.go b/contrib/fs/spartan.go index bee274a..d97edd1 100644 --- a/contrib/fs/spartan.go +++ b/contrib/fs/spartan.go @@ -1,10 +1,6 @@ package fs import ( - "context" - "os" - "path/filepath" - "strings" "text/template" sr "tildegit.org/tjp/sliderule" @@ -15,30 +11,7 @@ import ( // // 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) - }) + return fileHandler(spartan.ServerProtocol, fsroot, urlroot) } // SpartanDirectoryDefault serves up default files for directory path requests. @@ -52,47 +25,7 @@ func SpartanFileHandler(fsroot, urlroot string) sr.Handler { // 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 - }) + return directoryDefault(spartan.ServerProtocol, fsroot, urlroot, true, filenames...) } // SpartanDirectoryListing produces a listing of the contents of any requested directories. @@ -105,40 +38,11 @@ func SpartanDirectoryDefault(fsroot, urlroot string, filenames ...string) sr.Han // // 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) - }) +func SpartanDirectoryListing(fsroot, urlroot string, tmpl *template.Template) sr.Handler { + if tmpl == nil { + tmpl = DefaultSpartanDirectoryList + } + return directoryListing(spartan.ServerProtocol, fsroot, urlroot, "file.gmi", true, tmpl) } // DefaultSpartanDirectoryList is a tmeplate which renders a reasonable gemtext dir listing. diff --git a/gemini/protocol.go b/gemini/protocol.go new file mode 100644 index 0000000..e638ec8 --- /dev/null +++ b/gemini/protocol.go @@ -0,0 +1,28 @@ +package gemini + +import ( + "io" + "net/url" + + "tildegit.org/tjp/sliderule/internal" + "tildegit.org/tjp/sliderule/internal/types" +) + +type proto struct{} + +func (p proto) TemporaryRedirect(u *url.URL) *types.Response { return Redirect(u.String()) } +func (p proto) PermanentRedirect(u *url.URL) *types.Response { return PermanentRedirect(u.String()) } + +func (p proto) TemporaryServerError(err error) *types.Response { return Failure(err) } +func (p proto) PermanentServerError(err error) *types.Response { return PermanentFailure(err) } +func (p proto) CGIFailure(err error) *types.Response { return CGIError(err.Error()) } + +func (p proto) Success(filename string, body io.Reader) *types.Response { + return Success(internal.MediaType(filename), body) +} + +func (p proto) ParseResponse(input io.Reader) (*types.Response, error) { + return ParseResponse(input) +} + +var ServerProtocol types.ServerProtocol = proto{} diff --git a/gopher/gophermap/extended.go b/gopher/gophermap/extended.go index 8e48e99..7d64fe0 100644 --- a/gopher/gophermap/extended.go +++ b/gopher/gophermap/extended.go @@ -3,6 +3,7 @@ package gophermap import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -14,6 +15,7 @@ import ( "strconv" "strings" + sr "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gopher" "tildegit.org/tjp/sliderule/internal" "tildegit.org/tjp/sliderule/internal/types" @@ -298,3 +300,33 @@ func openExtended(path string, location *url.URL, settings FileSystemSettings) ( return ParseExtended(file, location) } + +func ExtendMiddleware(fsroot, urlroot string, settings *FileSystemSettings) sr.Middleware { + return sr.Middleware(func(handler sr.Handler) sr.Handler { + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { + response := handler.Handle(ctx, request) + + if !settings.ParseExtended || response.Status != gopher.MenuType { + return response + } + + defer func() { _ = response.Close() }() + + edoc, err := ParseExtended(response.Body, request.URL) + if err != nil { + return gopher.Error(err).Response() + } + + fpath := strings.TrimPrefix(request.Path, urlroot) + fpath = strings.Trim(fpath, "/") + fpath = filepath.Join(fsroot, fpath) + + doc, _, err := edoc.Compatible(filepath.Dir(fpath), *settings) + if err != nil { + return gopher.Error(err).Response() + } + + return doc.Response() + }) + }) +} diff --git a/gopher/protocol.go b/gopher/protocol.go new file mode 100644 index 0000000..22ccd56 --- /dev/null +++ b/gopher/protocol.go @@ -0,0 +1,27 @@ +package gopher + +import ( + "io" + "net/url" + + "tildegit.org/tjp/sliderule/internal/types" +) + +type proto struct{} + +func (p proto) TemporaryRedirect(u *url.URL) *types.Response { return nil } +func (p proto) PermanentRedirect(u *url.URL) *types.Response { return nil } + +func (p proto) TemporaryServerError(err error) *types.Response { return Error(err).Response() } +func (p proto) PermanentServerError(err error) *types.Response { return Error(err).Response() } +func (p proto) CGIFailure(err error) *types.Response { return Error(err).Response() } + +func (p proto) Success(filename string, body io.Reader) *types.Response { + return File(GuessItemType(filename), body) +} + +func (p proto) ParseResponse(input io.Reader) (*types.Response, error) { + return &types.Response{Body: input, Status: MenuType}, nil +} + +var ServerProtocol types.ServerProtocol = proto{} diff --git a/gopher/response.go b/gopher/response.go index 269176f..3651e07 100644 --- a/gopher/response.go +++ b/gopher/response.go @@ -5,12 +5,11 @@ import ( "fmt" "io" "mime" - "os" "path" "strings" "sync" - "unicode/utf8" + "tildegit.org/tjp/sliderule/internal" "tildegit.org/tjp/sliderule/internal/types" ) @@ -207,34 +206,9 @@ func GuessItemType(filepath string) types.Status { return TextFileType } - if contentsAreText(filepath) { + if internal.ContentsAreText(filepath) { return TextFileType } return BinaryFileType } - -func contentsAreText(filepath string) bool { - f, err := os.Open(filepath) - if err != nil { - return false - } - defer func() { _ = f.Close() }() - - var buf [1024]byte - n, err := f.Read(buf[:]) - if err != nil { - return false - } - - for i, c := range string(buf[:n]) { - if i+utf8.UTFMax > n { - // incomplete last char - break - } - if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' { - return false - } - } - return true -} diff --git a/internal/filetypes.go b/internal/filetypes.go new file mode 100644 index 0000000..6824ffc --- /dev/null +++ b/internal/filetypes.go @@ -0,0 +1,57 @@ +package internal + +import ( + "mime" + "os" + "strings" + "unicode/utf8" +) + +func MediaType(fpath string) string { + if strings.HasSuffix(fpath, ".gmi") { + // This may not be present in the listings searched by mime.TypeByExtension, + // so provide a dedicated fast path for it here. + return "text/gemini" + } + + slashIdx := strings.LastIndex(fpath, "/") + dotIdx := strings.LastIndex(fpath[slashIdx+1:], ".") + if dotIdx == -1 { + return "application/octet-stream" + } + ext := fpath[slashIdx+1+dotIdx:] + + mtype := mime.TypeByExtension(ext) + if mtype == "" { + if ContentsAreText(fpath) { + return "text/plain" + } + return "application/octet-stream" + } + return mtype +} + +func ContentsAreText(fpath string) bool { + f, err := os.Open(fpath) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + var buf [1024]byte + n, err := f.Read(buf[:]) + if err != nil { + return false + } + + for i, c := range string(buf[:n]) { + if i+utf8.UTFMax > n { + // incomplete last char + break + } + if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' { + return false + } + } + return true +} diff --git a/internal/types/protocol.go b/internal/types/protocol.go new file mode 100644 index 0000000..7166d8f --- /dev/null +++ b/internal/types/protocol.go @@ -0,0 +1,19 @@ +package types + +import ( + "io" + "net/url" +) + +type ServerProtocol interface { + TemporaryRedirect(*url.URL) *Response + PermanentRedirect(*url.URL) *Response + + TemporaryServerError(error) *Response + PermanentServerError(error) *Response + CGIFailure(error) *Response + + Success(filename string, body io.Reader) *Response + + ParseResponse(io.Reader) (*Response, error) +} @@ -3,3 +3,4 @@ package sliderule import "tildegit.org/tjp/sliderule/internal/types" type Server = types.Server +type ServerProtocol = types.ServerProtocol diff --git a/spartan/protocol.go b/spartan/protocol.go new file mode 100644 index 0000000..8e94857 --- /dev/null +++ b/spartan/protocol.go @@ -0,0 +1,26 @@ +package spartan + +import ( + "io" + "net/url" + + "tildegit.org/tjp/sliderule/internal" + "tildegit.org/tjp/sliderule/internal/types" +) + +type proto struct{} + +func (p proto) TemporaryRedirect(u *url.URL) *types.Response { return Redirect(u.String()) } +func (p proto) PermanentRedirect(u *url.URL) *types.Response { return Redirect(u.String()) } + +func (p proto) TemporaryServerError(err error) *types.Response { return ServerError(err) } +func (p proto) PermanentServerError(err error) *types.Response { return ServerError(err) } +func (p proto) CGIFailure(err error) *types.Response { return ServerError(err) } + +func (p proto) Success(filename string, body io.Reader) *types.Response { + return Success(internal.MediaType(filename), body) +} + +func (p proto) ParseResponse(input io.Reader) (*types.Response, error) { return ParseResponse(input) } + +var ServerProtocol types.ServerProtocol = proto{} |