summaryrefslogtreecommitdiff
path: root/contrib/fs/gemini.go
blob: 7549ce6f8f055feaa84c25f7222b3599f2ad9acd (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
package fs

import (
	"context"
	"crypto/tls"
	"io"
	"io/fs"
	"net/url"
	"os"
	"path"
	"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(approver tlsauth.Approver, rootdir string) sr.Middleware {
	rootdir = strings.TrimSuffix(rootdir, "/")

	return func(responder sr.Handler) sr.Handler {
		handler := sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
			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.TrimPrefix(request.Path, "/")
			filepath = path.Join(rootdir, 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 a file system.
//
// It only serves responses for paths which do not correspond to directories on disk.
func GeminiFileHandler(fileSystem fs.FS) sr.Handler {
	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
		filepath, file, err := ResolveFile(request, fileSystem)
		if err != nil {
			return gemini.Failure(err)
		}

		if file == nil {
			return nil
		}

		return gemini.Success(mediaType(filepath), 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 not the directory's contents to function properly.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they
// don't, it will produce nil responses for any directory paths.
func GeminiDirectoryDefault(fileSystem fs.FS, filenames ...string) sr.Handler {
	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
		dirpath, dir, response := handleDirGemini(request, fileSystem)
		if response != nil {
			return response
		}
		if dir == nil {
			return nil
		}
		defer func() { _ = dir.Close() }()

		filepath, file, err := ResolveDirectoryDefault(fileSystem, dirpath, dir, filenames)
		if err != nil {
			return gemini.Failure(err)
		}
		if file == nil {
			return nil
		}

		return gemini.Success(mediaType(filepath), file)
	})
}

// 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.
//
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they
// don't, it will produce "51 Not Found" responses for any directory paths.
//
// The template may be nil, in which case DefaultGeminiDirectoryList is used instead. The
// template is then processed with RenderDirectoryListing.
func GeminiDirectoryListing(fileSystem fs.FS, template *template.Template) sr.Handler {
	return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response {
		dirpath, dir, response := handleDirGemini(request, fileSystem)
		if response != nil {
			return response
		}
		if dir == nil {
			return nil
		}
		defer func() { _ = dir.Close() }()

		if template == nil {
			template = DefaultGeminiDirectoryList
		}
		body, err := RenderDirectoryListing(dirpath, dir, 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:]))

func handleDirGemini(request *sr.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *sr.Response) {
	path, dir, err := ResolveDirectory(request, fileSystem)
	if err != nil {
		return "", nil, gemini.Failure(err)
	}

	if dir == nil {
		return "", nil, nil
	}

	if !strings.HasSuffix(request.Path, "/") {
		_ = dir.Close()
		url := *request.URL
		url.Path += "/"
		return "", nil, gemini.PermanentRedirect(url.String())
	}

	return path, dir, nil
}