summaryrefslogtreecommitdiff
path: root/contrib/fs/gemini.go
blob: d0ad2d869e8b2d9900d393faca3410b0eac5fd4b (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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
package fs

import (
	"context"
	"crypto/tls"
	"io"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strings"
	"text/template"

	sr "tildegit.org/tjp/sliderule"
	"tildegit.org/tjp/sliderule/contrib/tlsauth"
	"tildegit.org/tjp/sliderule/gemini"
)

// TitanUpload decorates a handler to implement uploads via the titan protocol.
//
// It is a middleware rather than a handler because after the upload is processed,
// the server is still responsible for generating a response.
func TitanUpload(fsroot, urlroot string, approver tlsauth.Approver) sr.Middleware {
	fsroot = strings.TrimSuffix(fsroot, "/")

	return func(responder sr.Handler) sr.Handler {
		handler := sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
			if !strings.HasPrefix(request.Path, urlroot) {
				return nil
			}

			body := gemini.GetTitanRequestBody(request)

			tmpf, err := os.CreateTemp("", "titan_upload_")
			if err != nil {
				return gemini.PermanentFailure(err)
			}

			if _, err := io.Copy(tmpf, body); err != nil {
				_ = os.Remove(tmpf.Name())
				return gemini.PermanentFailure(err)
			}

			request = cloneRequest(request)
			request.Path = strings.SplitN(request.Path, ";", 2)[0]

			filepath := strings.Trim(strings.TrimPrefix(request.Path, urlroot), "/")
			filepath = path.Join(fsroot, filepath)
			if err := os.Rename(tmpf.Name(), filepath); err != nil {
				_ = os.Remove(tmpf.Name())
				return gemini.PermanentFailure(err)
			}

			return responder.Handle(ctx, request)
		})

		handler = tlsauth.GeminiAuth(approver)(handler)

		handler = sr.Filter(func(_ context.Context, r *sr.Request) bool {
			return gemini.GetTitanRequestBody(r) != nil
		}, nil)(handler)

		return handler
	}
}

func cloneRequest(start *sr.Request) *sr.Request {
	next := &sr.Request{}
	*next = *start

	next.URL = &url.URL{}
	*next.URL = *start.URL

	if start.TLSState != nil {
		next.TLSState = &tls.ConnectionState{}
		*next.TLSState = *start.TLSState
	}

	return next
}

// GeminiFileHandler builds a handler which serves up files from the file system.
//
// 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)
	})
}

// GeminiDirectoryDefault serves up default files for directory path requests.
//
// If any of the supported filenames are found, the contents of the file is returned
// as the gemini response.
//
// It returns nil for any paths which don't correspond to a 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 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.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
		}

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

// GeminiDirectoryListing produces a 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 not the directory's contents to function properly.
//
// 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)
	})
}

// DefaultGeminiDirectoryList is a template which renders a reasonable gemtext dir list.
var DefaultGeminiDirectoryList = template.Must(template.New("gemini_dirlist").Parse(`
# {{ .DirName }}
{{ range .Entries }}
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
{{ end }}
=> ../
`[1:]))