~sotirisp/kindleto

ab520df5bf68f45e7c2ae307a5a251924e14ef7e — Sotiris Papatheodorou 2 years ago 69dab04
Allow serving local files
4 files changed, 209 insertions(+), 8 deletions(-)

M README.md
A file/proxy.go
M gemini/gemtext.go
M handlers.go
M README.md => README.md +2 -2
@@ 3,8 3,8 @@
`kindleto` is a [Gemini](https://gemini.circumlunar.space/) 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
proxy](https://github.com/pgorman/gneto).
Kindle's web browser. Local files can be opened using the `file://` URL scheme.
It is based on the [Gneto proxy](https://github.com/pgorman/gneto).

## Supported devices


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

package file

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"git.sr.ht/~sotirisp/kindleto/binary"
	"git.sr.ht/~sotirisp/kindleto/gemini"
	"git.sr.ht/~sotirisp/kindleto/settings"
	"git.sr.ht/~sotirisp/kindleto/templates"
	"git.sr.ht/~sotirisp/kindleto/text"
	"git.sr.ht/~sotirisp/kindleto/util"
)

func supportedMediaType(mediaType string) bool {
	for _, mediaTypePrefix := range []string{"text/", "image/"} {
		if strings.HasPrefix(mediaType, mediaTypePrefix) {
			return true
		}
	}
	return false
}

func withTrailingSlash(path string) string {
	if path[len(path)-1] == '/' {
		return path
	}
	return path + "/"
}

// pathEscape percent-encodes everything but path separators in the path.
func pathEscape(path string) string {
	components := strings.Split(filepath.ToSlash(path), "/")
	for i, _ := range components {
		components[i] = url.PathEscape(components[i])
	}
	return filepath.FromSlash(strings.Join(components, "/"))
}

type dirReader struct {
	dir     string
	entries []os.DirEntry
	data    []byte
	err     error
}

func newDirReader(dirname string) *dirReader {
	r := new(dirReader)
	r.dir = dirname
	r.entries, r.err = os.ReadDir(dirname)
	text := "# " + withTrailingSlash(filepath.Base(dirname)) + "\n\n"
	if r.err != nil {
		text += fmt.Sprintf("Error: %v\n", r.err)
	}
	r.data = []byte(text)
	sort.Slice(r.entries,
		func(i, j int) bool {
			if r.entries[i].IsDir() == r.entries[j].IsDir() {
				return r.entries[i].Name() < r.entries[j].Name()
			}
			return r.entries[i].IsDir()
		})
	return r
}

func (r *dirReader) Read(p []byte) (int, error) {
	if len(r.data) == 0 {
		if len(r.entries) == 0 {
			return 0, io.EOF
		}
		entry := r.entries[0]
		path := filepath.Join(r.dir, entry.Name())
		line := "=> " + pathEscape(path) + " " + entry.Name()
		if entry.IsDir() {
			line = withTrailingSlash(line)
		}
		line += "\n"
		r.data = []byte(line)
		r.entries = r.entries[1:]
	}
	n := copy(p, r.data)
	r.data = r.data[n:]
	return n, nil
}

func init() {
	for _, ext := range []string{".gmi", ".gemini"} {
		err := mime.AddExtensionType(ext, "text/gemini")
		if err != nil {
			log.Println("file.Proxy:", err)
		}
	}
}

func Proxy(w http.ResponseWriter, r *http.Request, u *url.URL) error {
	filename, err := filepath.Abs(u.Path)
	if err != nil {
		return fmt.Errorf("file.Proxy: error resolving absolute path: %v", err)
	}
	filename, err = filepath.EvalSymlinks(filename)
	if err != nil {
		return fmt.Errorf("file.Proxy: error evaluating symbolic links: %v", err)
	}
	// TODO Should filenames be restricted to /mnt/us (USB storage)? Are
	// there any security implications?

	s, err := os.Stat(filename)
	if err != nil {
		return fmt.Errorf("file.Proxy: %v", err)
	}

	if s.IsDir() {
		// Attempt to open index.gmi or index.gemini but allow
		// explicitly listing the directory by ending the path in "/.".
		if !strings.HasSuffix(u.Path, "/.") {
			for _, ext := range []string{"gmi", "gemini"} {
				index := "/index." + ext
				s, err := os.Stat(filename + index)
				if err == nil && !s.IsDir() {
					uu, err := url.Parse(u.String() + index)
					if err == nil {
						return Proxy(w, r, uu)
					}
				}
			}
		}
		if settings.Current.LogLevel >= 2 {
			log.Printf("file.Proxy: %s directory\n", filename)
		}
		rd := bufio.NewReader(newDirReader(filename))
		source := r.URL.Query().Get("source") != ""
		td := templates.TemplateData{
			URL:       u.String(),
			ParentURL: util.ParentURL(*u),
			Charset:   "utf-8",
			Lang:      settings.Current.DefaultLang,
		}
		if source {
			return text.TextToHTML(w, u, rd, td)
		} else {
			return gemini.GemtextToHTML(w, u, rd, td)
		}
	} else {
		mediaType := mime.TypeByExtension(filepath.Ext(filename))
		if settings.Current.LogLevel >= 2 {
			log.Printf("file.Proxy: %s %s\n", filename, mediaType)
		}
		if !supportedMediaType(mediaType) {
			if mediaType == "" {
				return fmt.Errorf("Unable to determine media type")
			} else {
				return fmt.Errorf("Unsupported media type: %s", mediaType)
			}
		}
		isText := strings.HasPrefix(mediaType, "text/")
		if !isText && !settings.Current.ServeBinary {
			return fmt.Errorf("Serving binary files is disabled.")
		}

		file, err := os.Open(filename)
		if err != nil {
			return fmt.Errorf("Error reading file:", err)
		}
		defer file.Close()
		rd := bufio.NewReader(file)

		if isText {
			source := r.URL.Query().Get("source") != ""
			td := templates.TemplateData{
				URL:       u.String(),
				ParentURL: util.ParentURL(*u),
				Charset:   "utf-8",
				Lang:      settings.Current.DefaultLang,
			}
			if strings.HasPrefix(mediaType, "text/gemini") && !source {
				return gemini.GemtextToHTML(w, u, rd, td)
			} else {
				return text.TextToHTML(w, u, rd, td)
			}
		} else {
			return binary.Serve(w, r, u, rd)
		}
	}
}

M gemini/gemtext.go => gemini/gemtext.go +3 -2
@@ 100,9 100,10 @@ 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" {
			switch linkURL.Scheme {
			case "file", "finger", "gemini":
				io.WriteString(w, `/?url=`+gmi.QueryEscape(linkURLString))
			} else {
			default:
				io.WriteString(w, linkURLString)
			}
			io.WriteString(w, `">`+linkDesc+

M handlers.go => handlers.go +8 -4
@@ 18,6 18,7 @@ import (
	"strings"

	"git.sr.ht/~sotirisp/kindleto/certificates"
	"git.sr.ht/~sotirisp/kindleto/file"
	"git.sr.ht/~sotirisp/kindleto/finger"
	"git.sr.ht/~sotirisp/kindleto/gemini"
	"git.sr.ht/~sotirisp/kindleto/settings"


@@ 278,7 279,12 @@ func proxy(w http.ResponseWriter, r *http.Request) {
		return
	}

	if u.Scheme == "gemini" {
	switch u.Scheme {
	case "file":
		err = file.Proxy(w, r, u)
	case "finger":
		err = finger.Proxy(w, r, u)
	case "gemini":
		for i := 0; i <= settings.Current.MaxRedirects; i++ {
			u, err = gemini.ProxyGemini(w, r, u)
			if u != nil && u.Scheme != "gemini" {


@@ 301,9 307,7 @@ func proxy(w http.ResponseWriter, r *http.Request) {
			}
			break
		}
	} else if u.Scheme == "finger" {
		err = finger.Proxy(w, r, u)
	} else {
	default:
		err = fmt.Errorf("proxy: proxying of %s not supported (%s)", u.Scheme, u.String())
	}