summaryrefslogtreecommitdiff
path: root/contrib
diff options
context:
space:
mode:
Diffstat (limited to 'contrib')
-rw-r--r--contrib/fs/dir.go174
-rw-r--r--contrib/fs/file.go55
-rw-r--r--contrib/fs/stat.go28
-rw-r--r--contrib/log/log.go35
4 files changed, 292 insertions, 0 deletions
diff --git a/contrib/fs/dir.go b/contrib/fs/dir.go
new file mode 100644
index 0000000..b219e22
--- /dev/null
+++ b/contrib/fs/dir.go
@@ -0,0 +1,174 @@
+package fs
+
+import (
+ "bytes"
+ "context"
+ "io/fs"
+ "sort"
+ "strings"
+ "text/template"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+// DirectoryDefault handles directory path requests by looking for specific filenames.
+//
+// If any of the supported filenames are found, the contents of the file is returned
+// as the gemini response.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ for _, fileName := range fileNames {
+ for _, entry := range entries {
+ if entry.Name() == fileName {
+ file, err := fileSystem.Open(path + "/" + fileName)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ return gemini.Success(mediaType(fileName), file)
+ }
+ }
+ }
+
+ return gemini.NotFound("Resource does not exist.")
+ }
+}
+
+// DirectoryListing produces a gemtext listing of the contents of any requested directories.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+//
+// The template is provided the following namespace:
+// - .FullPath: the complete path to the listed directory
+// - .DirName: the name of the directory itself
+// - .Entries: the []fs.DirEntry of the directory contents
+//
+// The template argument may be nil, in which case a simple default template is used.
+func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ if template == nil {
+ template = defaultDirListTemplate
+ }
+
+ buf := &bytes.Buffer{}
+
+ environ, err := dirlistNamespace(path, dirFile)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if err := template.Execute(buf, environ); err != nil {
+ gemini.Failure(err)
+ }
+
+ return gemini.Success("text/gemini", buf)
+ }
+}
+
+var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
+# {{ .DirName }}
+{{ range .Entries }}
+=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
+{{ end }}
+=> ../
+`[1:]))
+
+func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name() < entries[j].Name()
+ })
+
+ var dirname string
+ if path == "." {
+ dirname = "(root)"
+ } else {
+ dirname = path[strings.LastIndex(path, "/")+1:]
+ }
+
+ m := map[string]any{
+ "FullPath": path,
+ "DirName": dirname,
+ "Entries": entries,
+ }
+
+ return m, nil
+}
+
+func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) {
+ path := strings.Trim(req.Path, "/")
+ if path == "" {
+ path = "."
+ }
+
+ file, err := fileSystem.Open(path)
+ if isNotFound(err) {
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+ if err != nil {
+ return "", nil, gemini.Failure(err)
+ }
+
+ isDir, err := fileIsDir(file)
+ if err != nil {
+ file.Close()
+ return "", nil, gemini.Failure(err)
+ }
+
+ if !isDir {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ if !strings.HasSuffix(req.Path, "/") {
+ file.Close()
+ url := *req.URL
+ url.Path += "/"
+ return "", nil, gemini.Redirect(url.String())
+ }
+
+ dirFile, ok := file.(fs.ReadDirFile)
+ if !ok {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ return path, dirFile, nil
+}
diff --git a/contrib/fs/file.go b/contrib/fs/file.go
new file mode 100644
index 0000000..cdcd1a9
--- /dev/null
+++ b/contrib/fs/file.go
@@ -0,0 +1,55 @@
+package fs
+
+import (
+ "context"
+ "io/fs"
+ "mime"
+ "strings"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+// FileHandler builds a handler function which serves up a file system.
+func FileHandler(fileSystem fs.FS) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
+ if isNotFound(err) {
+ return gemini.NotFound("Resource does not exist.")
+ }
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ isDir, err := fileIsDir(file)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if isDir {
+ return gemini.NotFound("Resource does not exist.")
+ }
+
+ return gemini.Success(mediaType(req.Path), file)
+ }
+}
+
+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+dotIdx:]
+
+ mtype := mime.TypeByExtension(ext)
+ if mtype == "" {
+ return "application/octet-stream"
+ }
+ return mtype
+}
diff --git a/contrib/fs/stat.go b/contrib/fs/stat.go
new file mode 100644
index 0000000..4dd65d8
--- /dev/null
+++ b/contrib/fs/stat.go
@@ -0,0 +1,28 @@
+package fs
+
+import (
+ "errors"
+ "io/fs"
+)
+
+func isNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var pathErr *fs.PathError
+ if errors.As(err, &pathErr) {
+ e := pathErr.Err
+ return errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist)
+ }
+
+ return false
+}
+
+func fileIsDir(file fs.File) (bool, error) {
+ info, err := file.Stat()
+ if err != nil {
+ return false, err
+ }
+ return info.IsDir(), nil
+}
diff --git a/contrib/log/log.go b/contrib/log/log.go
new file mode 100644
index 0000000..2ccd3bc
--- /dev/null
+++ b/contrib/log/log.go
@@ -0,0 +1,35 @@
+package log
+
+import (
+ "context"
+ "io"
+ "time"
+
+ kitlog "github.com/go-kit/log"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func Requests(out io.Writer, logger kitlog.Logger) gemini.Middleware {
+ if logger == nil {
+ logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
+ }
+
+ return func(next gemini.Handler) gemini.Handler {
+ return func(ctx context.Context, r *gemini.Request) (resp *gemini.Response) {
+ start := time.Now()
+ defer func() {
+ end := time.Now()
+ logger.Log(
+ "msg", "request",
+ "ts", end,
+ "dur", end.Sub(start),
+ "url", r.URL,
+ "status", resp.Status,
+ )
+ }()
+
+ return next(ctx, r)
+ }
+ }
+}