refactor contribs to work with a Protocol interface

This commit is contained in:
tjp 2023-11-13 07:25:39 -07:00
parent a808b46926
commit 1e0f8e0aae
18 changed files with 466 additions and 701 deletions

View File

@ -11,6 +11,7 @@ import (
"net"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
@ -25,50 +26,39 @@ 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
}
pathinfo := "/"
if len(segments) > i+1 {
pathinfo = path.Join(segments[i:]...)
}
return fullpath, pathinfo, nil
}
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
}
// readable + executable by anyone
return false, info.Mode()&5 == 5, nil
}
func isNotExistError(err error) bool {
if err != nil {
var pathErr *fs.PathError

View File

@ -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)
}

View File

@ -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)
}

57
contrib/cgi/handlers.go Normal file
View File

@ -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
})
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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.

View File

@ -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, "/")
if settings == nil {
return sr.HandlerFunc(func(_ context.Context, _ *sr.Request) *sr.Response { return nil })
}
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)
}
}
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))
}

162
contrib/fs/handlers.go Normal file
View File

@ -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
}

View File

@ -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.

28
gemini/protocol.go Normal file
View File

@ -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{}

View File

@ -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()
})
})
}

27
gopher/protocol.go Normal file
View File

@ -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{}

View File

@ -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
}

57
internal/filetypes.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -3,3 +3,4 @@ package sliderule
import "tildegit.org/tjp/sliderule/internal/types"
type Server = types.Server
type ServerProtocol = types.ServerProtocol

26
spartan/protocol.go Normal file
View File

@ -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{}