summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--contrib/cgi/cgi.go50
-rw-r--r--contrib/cgi/gemini.go51
-rw-r--r--contrib/cgi/gopher.go155
-rw-r--r--contrib/cgi/handlers.go57
-rw-r--r--contrib/cgi/spartan.go51
-rw-r--r--contrib/fs/file.go52
-rw-r--r--contrib/fs/gemini.go109
-rw-r--r--contrib/fs/gopher.go150
-rw-r--r--contrib/fs/handlers.go162
-rw-r--r--contrib/fs/spartan.go110
-rw-r--r--gemini/protocol.go28
-rw-r--r--gopher/gophermap/extended.go32
-rw-r--r--gopher/protocol.go27
-rw-r--r--gopher/response.go30
-rw-r--r--internal/filetypes.go57
-rw-r--r--internal/types/protocol.go19
-rw-r--r--server.go1
-rw-r--r--spartan/protocol.go26
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)
+}
diff --git a/server.go b/server.go
index 5bb8c97..c81d059 100644
--- a/server.go
+++ b/server.go
@@ -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{}