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