summaryrefslogtreecommitdiff
path: root/contrib/fs/dir.go
blob: b219e22f256f56abb6329471c81cc753586a3622 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
}