1 package gmitohtml
2
3 import (
4 "bytes"
5 "crypto/tls"
6 "crypto/x509"
7 "errors"
8 "fmt"
9 "html"
10 "io/ioutil"
11 "log"
12 "net/http"
13 "net/url"
14 "path"
15 "sort"
16 "strings"
17 "time"
18 )
19
20 var lastRequestTime = time.Now().Unix()
21
22 var (
23 clientCerts = make(map[string]tls.Certificate)
24 bookmarks = make(map[string]string)
25 bookmarksSorted []string
26 allowFileAccess bool
27 onBookmarksChanged func()
28 )
29
30 var defaultBookmarks = map[string]string{
31 "gemini://gemini.circumlunar.space/": "Project Gemini",
32 "gemini://gus.guru/": "GUS - Gemini Universal Search",
33 }
34
35
36 var ErrInvalidCertificate = errors.New("invalid certificate")
37
38 func bookmarksList() []byte {
39 fakeURL, _ := url.Parse("/")
40
41 var b bytes.Buffer
42 b.Write([]byte(`<ul>`))
43 for _, u := range bookmarksSorted {
44 b.Write([]byte(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, rewriteURL(u, fakeURL), bookmarks[u])))
45 }
46 b.Write([]byte("</ul>"))
47 return b.Bytes()
48 }
49
50
51 func fetch(u string) ([]byte, []byte, error) {
52 if u == "" {
53 return nil, nil, ErrInvalidURL
54 }
55
56 requestURL, err := url.ParseRequestURI(u)
57 if err != nil {
58 return nil, nil, err
59 }
60 if requestURL.Scheme == "" {
61 requestURL.Scheme = "gemini"
62 }
63
64 host := requestURL.Host
65 if strings.IndexRune(host, ':') == -1 {
66 host += ":1965"
67 }
68
69 tlsConfig := &tls.Config{
70
71
72 InsecureSkipVerify: true,
73 }
74
75 certHost := requestURL.Hostname()
76 if strings.HasPrefix(certHost, "www.") {
77 certHost = certHost[4:]
78 }
79
80 clientCert, certAvailable := clientCerts[certHost]
81 if certAvailable {
82 tlsConfig.Certificates = []tls.Certificate{clientCert}
83 }
84
85 conn, err := tls.Dial("tcp", host, tlsConfig)
86 if err != nil {
87 return nil, nil, err
88 }
89
90
91 conn.Write([]byte(requestURL.String() + "\r\n"))
92
93 data, err := ioutil.ReadAll(conn)
94 if err != nil {
95 return nil, nil, err
96 }
97
98 firstNewLine := -1
99 l := len(data)
100 if l > 2 {
101 for i := 1; i < l; i++ {
102 if data[i] == '\n' && data[i-1] == '\r' {
103 firstNewLine = i
104 break
105 }
106 }
107 }
108 var header []byte
109 if firstNewLine > -1 {
110 header = data[:firstNewLine]
111 data = data[firstNewLine+1:]
112 }
113
114 requestInput := bytes.HasPrefix(header, []byte("1"))
115 if requestInput {
116 requestSensitiveInput := bytes.HasPrefix(header, []byte("11"))
117
118 data = newPage()
119
120 data = append(data, []byte(inputPrompt)...)
121
122 data = bytes.Replace(data, []byte("~GEMINIINPUTFORM~"), []byte(html.EscapeString(rewriteURL(u, requestURL))), 1)
123
124 prompt := "(No input prompt)"
125 if len(header) > 3 {
126 prompt = string(header[3:])
127 }
128 data = bytes.Replace(data, []byte("~GEMINIINPUTPROMPT~"), []byte(prompt), 1)
129
130 inputType := "text"
131 if requestSensitiveInput {
132 inputType = "password"
133 }
134 data = bytes.Replace(data, []byte("~GEMINIINPUTTYPE~"), []byte(inputType), 1)
135
136 return header, fillTemplateVariables(data, u, false), nil
137 }
138
139 if !bytes.HasPrefix(header, []byte("2")) {
140 errorPage := newPage()
141 errorPage = append(errorPage, []byte(fmt.Sprintf("Server sent unexpected header:<br><br><b>%s</b>", header))...)
142 errorPage = append(errorPage, []byte(pageFooter)...)
143 return header, fillTemplateVariables(errorPage, u, false), nil
144 }
145
146 if bytes.HasPrefix(header, []byte("20 text/html")) {
147 return header, data, nil
148 }
149 return header, Convert(data, requestURL.String()), nil
150 }
151
152 func handleIndex(writer http.ResponseWriter, request *http.Request) {
153 address := request.FormValue("address")
154 if address != "" {
155 http.Redirect(writer, request, rewriteURL(address, request.URL), http.StatusSeeOther)
156 return
157 }
158
159 page := newPage()
160 page = append(page, bookmarksList()...)
161 page = append(page, pageFooter...)
162
163 writer.Write(fillTemplateVariables(page, request.URL.String(), true))
164 }
165
166 func fillTemplateVariables(data []byte, currentURL string, autofocus bool) []byte {
167 if strings.HasPrefix(currentURL, "gemini://") {
168 currentURL = currentURL[9:]
169 }
170 if currentURL == "/" {
171 currentURL = ""
172 }
173 data = bytes.ReplaceAll(data, []byte("~GEMINICURRENTURL~"), []byte(currentURL))
174
175 autofocusValue := ""
176 if autofocus {
177 autofocusValue = "autofocus"
178 }
179 data = bytes.ReplaceAll(data, []byte("~GEMINIAUTOFOCUS~"), []byte(autofocusValue))
180
181 return data
182 }
183
184 func handleRequest(writer http.ResponseWriter, request *http.Request) {
185 defer request.Body.Close()
186
187 lastRequestTime = time.Now().Unix()
188
189 if request.URL == nil {
190 return
191 }
192
193 if request.URL.Path == "/" {
194 handleIndex(writer, request)
195 return
196 }
197
198 pathSplit := strings.Split(request.URL.Path, "/")
199 if len(pathSplit) < 2 || (pathSplit[1] != "gemini" && (!allowFileAccess || pathSplit[1] != "file")) {
200 writer.Write([]byte("Error: invalid protocol, only Gemini is supported"))
201 return
202 }
203
204 scheme := "gemini://"
205 if pathSplit[1] == "file" {
206 scheme = "file://"
207 }
208
209 u, err := url.ParseRequestURI(scheme + strings.Join(pathSplit[2:], "/"))
210 if err != nil {
211 writer.Write([]byte("Error: invalid URL"))
212 return
213 }
214 if request.URL.RawQuery != "" {
215 u.RawQuery = request.URL.RawQuery
216 }
217
218 inputText := request.PostFormValue("input")
219 if inputText != "" {
220 u.RawQuery = inputText
221 http.Redirect(writer, request, rewriteURL(u.String(), u), http.StatusSeeOther)
222 return
223 }
224
225 var header []byte
226 var data []byte
227 if scheme == "gemini://" {
228 header, data, err = fetch(u.String())
229 if err != nil {
230 fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err)
231 return
232 }
233 } else if allowFileAccess && scheme == "file://" {
234 header = []byte("20 text/gemini; charset=utf-8")
235 data, err = ioutil.ReadFile(path.Join("/", strings.Join(pathSplit[2:], "/")))
236 if err != nil {
237 fmt.Fprintf(writer, "Error: failed to read file %s: %s", u, err)
238 return
239 }
240 data = Convert(data, u.String())
241 } else {
242 writer.Write([]byte("Error: invalid URL"))
243 return
244 }
245
246 if len(header) > 0 && header[0] == '3' {
247 split := bytes.SplitN(header, []byte(" "), 2)
248 if len(split) == 2 {
249 http.Redirect(writer, request, rewriteURL(string(split[1]), u), http.StatusSeeOther)
250 return
251 }
252 }
253
254 if len(header) > 3 && !bytes.HasPrefix(header[3:], []byte("text/gemini")) {
255 writer.Header().Set("Content-Type", string(header[3:]))
256 } else {
257 writer.Header().Set("Content-Type", "text/html; charset=utf-8")
258 }
259
260 writer.Write(data)
261 }
262
263 func handleAssets(writer http.ResponseWriter, request *http.Request) {
264 assetLock.Lock()
265 defer assetLock.Unlock()
266
267 writer.Header().Set("Cache-Control", "max-age=86400")
268
269 http.FileServer(fs).ServeHTTP(writer, request)
270 }
271
272 func handleBookmarks(writer http.ResponseWriter, request *http.Request) {
273 writer.Header().Set("Content-Type", "text/html; charset=utf-8")
274 var data []byte
275
276 postAddress := request.PostFormValue("address")
277 postLabel := request.PostFormValue("label")
278 if postLabel == "" && postAddress != "" {
279 postLabel = postAddress
280 }
281
282 editBookmark := request.FormValue("edit")
283 if editBookmark != "" {
284 if postLabel == "" {
285 label, ok := bookmarks[editBookmark]
286 if !ok {
287 writer.Write([]byte("<h1>Error: bookmark not found</h1>"))
288 return
289 }
290
291 data = newPage()
292
293 data = append(data, []byte(fmt.Sprintf(`<form method="post" action="%s"><h3>Edit bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" autofocus><br><br><input type="text" size="40" name="label" placeholder="Label" value="%s"><br><br><input type="submit" value="Update"></form>`, request.URL.Path+"?"+request.URL.RawQuery, html.EscapeString(editBookmark), html.EscapeString(label)))...)
294
295 data = append(data, []byte(pageFooter)...)
296
297 writer.Write(fillTemplateVariables(data, "", false))
298 return
299 }
300
301 if editBookmark != postAddress || bookmarks[editBookmark] != postLabel {
302 RemoveBookmark(editBookmark)
303 AddBookmark(postAddress, postLabel)
304 }
305 } else if postLabel != "" {
306 AddBookmark(postAddress, postLabel)
307 }
308
309 deleteBookmark := request.FormValue("delete")
310 if deleteBookmark != "" {
311 RemoveBookmark(deleteBookmark)
312 }
313
314 data = newPage()
315
316 addBookmark := request.FormValue("add")
317
318 addressFocus := "autofocus"
319 labelFocus := ""
320 if addBookmark != "" {
321 addressFocus = ""
322 labelFocus = "autofocus"
323 }
324
325 data = append(data, []byte(fmt.Sprintf(`<form method="post" action="/bookmarks"><h3>Add bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" %s><br><br><input type="text" size="40" name="label" placeholder="Label" %s><br><br><input type="submit" value="Add"></form>`, html.EscapeString(addBookmark), addressFocus, labelFocus))...)
326
327 if len(bookmarks) > 0 && addBookmark == "" {
328 fakeURL, _ := url.Parse("/")
329
330 data = append(data, []byte(`<br><h3>Bookmarks</h3><table border="1" cellpadding="5">`)...)
331 for _, u := range bookmarksSorted {
332 data = append(data, []byte(fmt.Sprintf(`<tr><td>%s<br><a href="%s">%s</a></td><td><a href="/bookmarks?edit=%s" class="navlink">Edit</a></td><td><a href="/bookmarks?delete=%s" onclick="return confirm('Are you sure you want to delete this bookmark?')" class="navlink">Delete</a></td></tr>`, html.EscapeString(bookmarks[u]), html.EscapeString(rewriteURL(u, fakeURL)), html.EscapeString(u), html.EscapeString(url.PathEscape(u)), html.EscapeString(url.PathEscape(u))))...)
333 }
334 data = append(data, []byte(`</table>`)...)
335 }
336
337 data = append(data, []byte(pageFooter)...)
338
339 writer.Write(fillTemplateVariables(data, "", false))
340 }
341
342
343 func SetOnBookmarksChanged(f func()) {
344 onBookmarksChanged = f
345 }
346
347
348 func StartDaemon(address string, hostname string, allowFile bool) error {
349 daemonAddress = address
350 if hostname != "" {
351 daemonAddress = hostname
352 }
353 allowFileAccess = allowFile
354
355 loadAssets()
356
357 if len(bookmarks) == 0 {
358 for u, label := range defaultBookmarks {
359 AddBookmark(u, label)
360 }
361 }
362
363 handler := http.NewServeMux()
364 handler.HandleFunc("/assets/style.css", handleAssets)
365 handler.HandleFunc("/bookmarks", handleBookmarks)
366 handler.HandleFunc("/", handleRequest)
367 go func() {
368 log.Fatal(http.ListenAndServe(address, handler))
369 }()
370
371 return nil
372 }
373
374
375 func LastRequestTime() int64 {
376 return lastRequestTime
377 }
378
379
380 func SetClientCertificate(domain string, certificate []byte, privateKey []byte) error {
381 if len(certificate) == 0 || len(privateKey) == 0 {
382 delete(clientCerts, domain)
383 return nil
384 }
385
386 clientCert, err := tls.X509KeyPair(certificate, privateKey)
387 if err != nil {
388 return ErrInvalidCertificate
389 }
390
391 leafCert, err := x509.ParseCertificate(clientCert.Certificate[0])
392 if err == nil {
393 clientCert.Leaf = leafCert
394 }
395
396 clientCerts[domain] = clientCert
397 return nil
398 }
399
400
401 func AddBookmark(u string, label string) {
402 parsed, err := url.Parse(u)
403 if err != nil {
404 return
405 }
406 if parsed.Scheme == "" {
407 parsed.Scheme = "gemini"
408 }
409 parsed.Host = strings.ToLower(parsed.Host)
410
411 bookmarks[parsed.String()] = label
412
413 bookmarksUpdated()
414 }
415
416
417 func GetBookmarks() map[string]string {
418 return bookmarks
419 }
420
421
422 func RemoveBookmark(u string) {
423 delete(bookmarks, u)
424
425 bookmarksUpdated()
426 }
427
428 func bookmarksUpdated() {
429 var allURLs []string
430 for u := range bookmarks {
431 allURLs = append(allURLs, u)
432 }
433 sort.Slice(allURLs, func(i, j int) bool {
434 return strings.ToLower(bookmarks[allURLs[i]]) < strings.ToLower(bookmarks[allURLs[j]])
435 })
436
437 bookmarksSorted = allURLs
438
439 if onBookmarksChanged != nil {
440 onBookmarksChanged()
441 }
442 }
443
View as plain text