~sotirisp/kindleto

3d9d9caa69f046206266e98b144d356f2439fcf4 — Sotiris Papatheodorou 3 months ago ea85837
Add support for the Gopher protocol
M README.md => README.md +4 -3
@@ 1,9 1,10 @@
# kindleto

`kindleto` is a [Gemini](https://gemini.circumlunar.space/) and
`kindleto` is a [Gemini](https://gemini.circumlunar.space/),
[Gopher](https://en.wikipedia.org/wiki/Gopher_(protocol)) and
[Finger](https://en.wikipedia.org/wiki/Finger_%28protocol%29) protocol proxy
for older Amazon Kindles. It allows accessing Gemini and Finger through the
Kindle's web browser. It is based on the [Gneto
for older Amazon Kindles. It allows accessing Gemini, Gopher and Finger through
the Kindle's web browser. It is based on the [Gneto
proxy](https://github.com/pgorman/gneto).

## Supported devices

M gemini/gemtext.go => gemini/gemtext.go +1 -1
@@ 99,7 99,7 @@ func gemtextToHTML(w http.ResponseWriter, u *url.URL, rd *bufio.Reader, td templ
			linkURL, _ := util.AbsoluteURL(u, linkURLString)
			linkURLString = linkURL.String()
			io.WriteString(w, `<a href="`)
			if linkURL.Scheme == "gemini" || linkURL.Scheme == "finger" {
			if linkURL.Scheme == "gemini" || linkURL.Scheme == "gopher" || linkURL.Scheme == "finger" {
				io.WriteString(w, `/?url=`+gemini.QueryEscape(linkURLString))
			} else {
				io.WriteString(w, linkURLString)

M go.mod => go.mod +4 -1
@@ 2,4 2,7 @@ module git.sr.ht/~sotirisp/kindleto

go 1.15

require git.sr.ht/~adnano/go-gemini v0.2.2
require (
	git.mills.io/prologic/go-gopher v0.0.0-20220131134120-44dd1c17a0dd
	git.sr.ht/~adnano/go-gemini v0.2.2
)

M go.sum => go.sum +15 -0
@@ 1,5 1,16 @@
git.mills.io/prologic/go-gopher v0.0.0-20220131134120-44dd1c17a0dd h1:XTWDxTxuxh7Wq5GrvpWxxEU16iPIa+xyYaVLEqKK9N0=
git.mills.io/prologic/go-gopher v0.0.0-20220131134120-44dd1c17a0dd/go.mod h1:EMXlYOIbYJQhPTtIltgaaHtCYDawV/HL0dYf8ShzAck=
git.sr.ht/~adnano/go-gemini v0.2.2 h1:p2owKzrQ1wTgvPS5CZCPYArQyNUL8ZgYOHHrTjH9sdI=
git.sr.ht/~adnano/go-gemini v0.2.2/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=


@@ 7,3 18,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

A gopher/gopher.go => gopher/gopher.go +115 -0
@@ 0,0 1,115 @@
// SPDX-FileCopyrightText: 2022 Sotiris Papatheodorou
// SPDX-License-Identifier: GPL-3.0-or-later

package gopher

import (
	"bufio"
	"fmt"
	"git.mills.io/prologic/go-gopher"
	"log"
	"net/http"
	"net/url"
	"strings"

	"git.sr.ht/~sotirisp/kindleto/templates"
	"git.sr.ht/~sotirisp/kindleto/util"
)

// ProxyGopher finds the Gopher content at u.
func ProxyGopher(w http.ResponseWriter, r *http.Request, u *url.URL) error {
	response, err := gopher.Get(u.String())
	if err != nil {
		return fmt.Errorf("ProxyGopher: gopher.Get error for %s: %v", u.String(), err)
	}
	if util.LogLevel > 1 {
		log.Printf("ProxyGopher: %v response: %v", u, response.Type)
	}

	td := templates.TemplateData{
		URL:       u.String(),
		ParentURL: util.ParentURL(u),
	}

	// Received a resource other than a Gopher directory.
	if response.Body != nil {
		rd := bufio.NewReader(response.Body)
		switch response.Type {
		case gopher.FILE, gopher.ERROR:
			return util.TextToHTML(w, u, rd, td)
		case gopher.BINHEX, gopher.DOSARCHIVE, gopher.UUENCODED,
			gopher.BINARY, gopher.GIF, gopher.IMAGE, gopher.HTML,
			gopher.AUDIO, gopher.PNG, gopher.DOC:
			return util.ServeFile(w, r, u, rd)
		}
	}

	// Received a Gopher directory.
	if r.URL.Query().Get("source") == "" {
		return gopherMenuToHTML(w, u, &response.Dir, td)
	} else {
		return gopherMenuSourceToHTML(w, u, &response.Dir, td)
	}
}

// HandleGopher handles full text search requests.
func HandleGopher(w http.ResponseWriter, r *http.Request) {
	if util.LogLevel > 1 {
		log.Printf("HandleGopher: method: %v url: %q query: %q",
			r.Method, r.FormValue("url"), r.FormValue("query"))
	}
	switch r.Method {
	case http.MethodGet:
		if r.FormValue("url") == "" {
			log.Printf(`HandleGopher: missing field "url"`)
			http.Error(w, "Bad Request", 400)
			return
		}
		u, err := url.Parse(r.FormValue("url"))
		if err != nil {
			log.Printf(`HandleGopher: failed to parse URL "%s": %v`,
				r.FormValue("url"), err)
			http.Error(w, "Internal Server Error", 500)
			return
		}
		td := templates.TemplateData{
			URL:       u.String(),
			ParentURL: util.ParentURL(u),
		}
		err = templates.Templates.ExecuteTemplate(w, "gopher-search.html.tmpl", td)
		if err != nil {
			log.Printf("HandleGopher: failed to execute input template: %v", err)
			http.Error(w, "Internal Server Error", 500)
			return
		}
	case http.MethodPost:
		if r.FormValue("url") == "" {
			log.Printf(`HandleGopher: missing field "url"`)
			http.Error(w, "Bad Request", 400)
			return
		}
		if r.FormValue("query") == "" {
			log.Printf(`HandleGopher: missing field "query"`)
			http.Error(w, "Bad Request", 400)
			return
		}
		escaped_query := strings.ReplaceAll(url.PathEscape(r.FormValue("query")), "+", "%2B")
		u, err := url.Parse(r.FormValue("url") + "?" + escaped_query)
		if err != nil {
			log.Printf(`HandleGopher: failed to parse URL "%s": %v`, r.FormValue("url"), err)
			http.Error(w, "Internal Server Error", 500)
			return
		}
		if util.LogLevel > 1 {
			log.Printf(`HandleGopher: gopher search URL: "%s"`, u)
		}
		err = ProxyGopher(w, r, u)
		if err != nil {
			log.Printf("HandleGopher: gopher error: %v", err)
			http.Error(w, "Internal Server Error", 500)
			return
		}
	default:
		http.Error(w, "Bad Request", 400)
	}
}

A gopher/gopher_menu.go => gopher/gopher_menu.go +114 -0
@@ 0,0 1,114 @@
// SPDX-FileCopyrightText: 2022 Sotiris Papatheodorou
// SPDX-License-Identifier: GPL-3.0-or-later

package gopher

import (
	"fmt"
	"git.mills.io/prologic/go-gopher"
	"io"
	"log"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"git.sr.ht/~sotirisp/kindleto/templates"
	"git.sr.ht/~sotirisp/kindleto/util"
)

// gopherMenuSourceToHTML parses a Gopher directory from d, and writes the HTML
// equivalent of its original format to w. The source URL is stored in u.
// util.TextToHTML wasn't used because go-gopher doesn't present a reader to
// the raw directory data.
func gopherMenuSourceToHTML(w http.ResponseWriter, u *url.URL, d *gopher.Directory, td templates.TemplateData) error {
	var err error
	err = templates.Templates.ExecuteTemplate(w, "header-only.html.tmpl", td)
	if err != nil {
		log.Println("gopherMenuSourceToHTML:", err)
		http.Error(w, "Internal Server Error", 500)
	}

	io.WriteString(w, `<pre class="text">`+"\n")
	for _, i := range d.Items {
		// MarshalText should only fail if there is a bug in
		// go-gemini since the Item was generated by go-gemini.
		t, _ := i.MarshalText()
		io.WriteString(w, string(t))
	}
	io.WriteString(w, "</pre>\n")

	err = templates.Templates.ExecuteTemplate(w, "footer-only.html.tmpl", td)
	if err != nil {
		log.Println("gopherMenuSourceToHTML:", err)
		http.Error(w, "Internal Server Error", 500)
	}

	return err
}

// gopherMenuToHTML parses a Gopher directory from d, and writes its HTML
// equivalent to w. The source URL is stored in u.
func gopherMenuToHTML(w http.ResponseWriter, u *url.URL, d *gopher.Directory, td templates.TemplateData) error {
	var err error
	err = templates.Templates.ExecuteTemplate(w, "header-only.html.tmpl", td)
	if err != nil {
		log.Println("gopherMenuToHTML:", err)
		http.Error(w, "Internal Server Error", 500)
	}

	io.WriteString(w, `<pre class="gopher-directory">`+"\n")
	for _, i := range d.Items {
		if util.LogLevel > 2 {
			// MarshalText should only fail if there is a bug in
			// go-gemini since the Item was generated by go-gemini.
			t, _ := i.MarshalText()
			fmt.Print(string(t))
		}

		i.Description = util.StripSGR(i.Description)

		switch i.Type {
		case gopher.ERROR, gopher.INFO:
			io.WriteString(w, i.Description+"\n")
		case gopher.FILE, gopher.DIRECTORY, gopher.BINHEX,
			gopher.DOSARCHIVE, gopher.UUENCODED,
			gopher.INDEXSEARCH, gopher.BINARY, gopher.REDUNDANT,
			gopher.GIF, gopher.IMAGE, gopher.AUDIO, gopher.PNG,
			gopher.DOC:
			link := url.URL{
				Scheme: "gopher",
				Host:   i.Host + ":" + strconv.Itoa(i.Port),
				Path:   string(i.Type) + i.Selector,
			}
			io.WriteString(w, `<a href="`)
			if i.Type == gopher.INDEXSEARCH {
				io.WriteString(w, `/gopher?url=`+url.QueryEscape(link.String()))
			} else {
				io.WriteString(w, `/?url=`+url.PathEscape(link.String()))
			}
			io.WriteString(w, `">`+i.Description+"</a> ")
			io.WriteString(w, `<span class="scheme">`+
				i.Type.String()+"</span>\n")
		case gopher.HTML:
			io.WriteString(w, `<a href="`+
				strings.TrimPrefix(i.Selector, "URL:")+`">`+
				i.Description+"</a> ")
			io.WriteString(w, `<span class="scheme">`+
				i.Type.String()+"</span>\n")
		// TODO handle PHONEBOOK, REDUNDANT
		default:
			io.WriteString(w, i.Description)
			io.WriteString(w, ` <span class="scheme">`+
				i.Type.String()+" UNSUPPORTED ITEM TYPE</span>\n")
		}
	}
	io.WriteString(w, "</pre>\n")

	err = templates.Templates.ExecuteTemplate(w, "footer-only.html.tmpl", td)
	if err != nil {
		log.Println("gopherMenuToHTML:", err)
		http.Error(w, "Internal Server Error", 500)
	}
	return err
}

M handlers.go => handlers.go +3 -0
@@ 18,6 18,7 @@ import (
	"git.sr.ht/~sotirisp/kindleto/certificates"
	"git.sr.ht/~sotirisp/kindleto/finger"
	"git.sr.ht/~sotirisp/kindleto/gemini"
	"git.sr.ht/~sotirisp/kindleto/gopher"
	"git.sr.ht/~sotirisp/kindleto/templates"
	"git.sr.ht/~sotirisp/kindleto/util"
)


@@ 243,6 244,8 @@ func proxy(w http.ResponseWriter, r *http.Request) {
			}
			break
		}
	} else if u.Scheme == "gopher" {
		err = gopher.ProxyGopher(w, r, u)
	} else if u.Scheme == "finger" {
		err = finger.ProxyFinger(w, r, u)
	} else {

M kindleto.go => kindleto.go +2 -0
@@ 16,6 16,7 @@ import (

	"git.sr.ht/~sotirisp/kindleto/certificates"
	"git.sr.ht/~sotirisp/kindleto/gemini"
	"git.sr.ht/~sotirisp/kindleto/gopher"
	"git.sr.ht/~sotirisp/kindleto/templates"
	"git.sr.ht/~sotirisp/kindleto/tofu"
	"git.sr.ht/~sotirisp/kindleto/util"


@@ 72,6 73,7 @@ func main() {

	mux := http.NewServeMux()
	mux.HandleFunc("/", proxy)
	mux.HandleFunc("/gopher", gopher.HandleGopher)
	mux.HandleFunc("/certificate/", clientCertificateRequired)
	mux.HandleFunc("/settings/certificates/", manageClientCertificates)
	mux.HandleFunc("/kindleto.css", css)

M templates/template_strings.go => templates/template_strings.go +13 -0
@@ 100,6 100,19 @@ const GeminiSensitiveInput string = `{{template "header" .}}
{{end}}
{{template "footer"}}`

const GopherSearch string = `{{template "header" .}}
{{if .Error}}
<div class="error">ERROR: {{.Error}}</div>
{{end}}
<h1>Gopher full-text search</h1>
<p>Enter a search query to send to<br/>{{.URL}}</p>
<form action="/gopher" method="POST">
<input type="hidden" name="url" value="{{.URL}}">
<input type="text" name="query">
<input type="submit" value="Search">
</form>
{{template "footer"}}`

const HeaderOnly string = `{{template "header" .}}
{{if .Error}}<div class="error">ERROR: {{.Error}}</div>{{end}}
{{if .Warning}}<div class="warning">Warning: {{.Warning}}</div>{{end}}`

M templates/templates.go => templates/templates.go +1 -0
@@ 38,6 38,7 @@ func LoadTemplates(webRoot string) {
	Templates = template.Must(Templates.New("gemini-certificate.html.tmpl").Parse(GeminiCertificate))
	Templates = template.Must(Templates.New("gemini-input.html.tmpl").Parse(GeminiInput))
	Templates = template.Must(Templates.New("gemini-sensitive-input.html.tmpl").Parse(GeminiSensitiveInput))
	Templates = template.Must(Templates.New("gopher-search.html.tmpl").Parse(GopherSearch))
	Templates = template.Must(Templates.New("header-only.html.tmpl").Parse(HeaderOnly))
	Templates = template.Must(Templates.New("header.html.tmpl").Parse(Header))
	Templates = template.Must(Templates.New("help.html.tmpl").Parse(Help))