commit 260018cc9d5f1f7f381a3ca04f2bd98120a2e3cb Author: Trevor Slocum Date: Sat Nov 21 08:53:04 2020 -0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17ebbdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +dist/ +vendor/ +*.sh +/gmitohtml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..221498a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,21 @@ +image: golang:latest + +stages: + - validate + - build + +fmt: + stage: validate + script: + - gofmt -l -s -e . + - exit $(gofmt -l -s -e . | wc -l) + +vet: + stage: validate + script: + - go vet -composites=false ./... + +test: + stage: validate + script: + - go test -race -v ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c20f89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Trevor Slocum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a9a2f2 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# gmitohtml +[![CI status](https://gitlab.com/tslocum/gmitohtml/badges/master/pipeline.svg)](https://gitlab.com/tslocum/gmitohtml/commits/master) +[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space) + +[Gemini](https://gemini.circumlunar.space) to HTML conversion tool and daemon + +## Download + +gmitohtml is written in [Go](https://golang.org). Run the following command to +download and build gmitohtml from source. + +```bash +go get gitlab.com/tslocum/gmitohtml +``` + +The resulting binary is available as `~/go/bin/gmitohtml`. + +## Usage + +Convert a single document: + +```bash +gmitohtml < document.gmi +``` + +Run as daemon at `http://localhost:8080`: + +```bash +# Start the daemon: +gmitohtml --daemon=localhost:8080 +# Access via browser: +xdg-open http://localhost:8080/gemini/twins.rocketnine.space/ +``` + +## Support + +Please share issues and suggestions [here](https://gitlab.com/tslocum/gmitohtml/issues). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..06d5296 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitlab.com/tslocum/gmitohtml + +go 1.15 diff --git a/main.go b/main.go new file mode 100644 index 0000000..26d3e93 --- /dev/null +++ b/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "gitlab.com/tslocum/gmitohtml/pkg/gmitohtml" + + "flag" + "fmt" + "io/ioutil" + "log" + "net/url" + "os" + "os/exec" + "runtime" +) + +func openBrowser(url string) { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + if err != nil { + log.Fatal(err) + } +} + +func main() { + var view bool + var daemon string + flag.BoolVar(&view, "view", false, "open web browser") + flag.StringVar(&daemon, "daemon", "", "start daemon on specified address") + // TODO option to include response header in page + flag.Parse() + + if daemon != "" { + err := gmitohtml.StartDaemon(daemon) + if err != nil { + log.Fatal(err) + } + + if view { + openBrowser("http://" + daemon) + } + + select {} //TODO + } + + data, err := ioutil.ReadAll(os.Stdin) + if err != nil { + log.Fatal(err) + } + + data = gmitohtml.Convert(data, "") + + data = append([]byte("\n\n\n"), data...) + data = append(data, []byte("\n\n")...) + + if view { + openBrowser(string(append([]byte("data:text/html,"), []byte(url.PathEscape(string(data)))...))) + return + } + fmt.Print(gmitohtml.Convert(data, "")) +} diff --git a/pkg/gmitohtml/convert.go b/pkg/gmitohtml/convert.go new file mode 100644 index 0000000..9a3de0c --- /dev/null +++ b/pkg/gmitohtml/convert.go @@ -0,0 +1,217 @@ +package gmitohtml + +import ( + "bufio" + "bytes" + "crypto/tls" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "path" + "strings" +) + +// ErrInvalidURL is the error returned when the URL is invalid. +var ErrInvalidURL = errors.New("invalid URL") + +var daemonAddress string + +func rewriteURL(u string, loc *url.URL) string { + if daemonAddress != "" { + if strings.HasPrefix(u, "gemini://") { + return "http://" + daemonAddress + "/gemini/" + u[9:] + } else if strings.Contains(u, "://") { + return u + } else if loc != nil && len(u) > 0 && !strings.HasPrefix(u, "//") { + newPath := u + if u[0] != '/' { + newPath = path.Join(loc.Path, u) + } + return "http://" + daemonAddress + "/gemini/" + loc.Host + newPath + } + return "http://" + daemonAddress + "/gemini/" + u + } + return u +} + +// Convert converts text/gemini to text/html. +func Convert(page []byte, u string) []byte { + var result []byte + + var lastPreformatted bool + var preformatted bool + + parsedURL, err := url.Parse(u) + if err != nil { + parsedURL = nil + err = nil + } + + scanner := bufio.NewScanner(bytes.NewReader(page)) + for scanner.Scan() { + line := scanner.Bytes() + l := len(line) + if l >= 3 && string(line[0:3]) == "```" { + preformatted = !preformatted + continue + } + + if preformatted != lastPreformatted { + if preformatted { + result = append(result, []byte("
\n")...)
+			} else {
+				result = append(result, []byte("
\n")...) + } + lastPreformatted = preformatted + } + + if preformatted { + result = append(result, line...) + result = append(result, []byte("\n")...) + continue + } + + if l >= 7 && bytes.HasPrefix(line, []byte("=> ")) { + split := bytes.SplitN(line[3:], []byte(" "), 2) + if len(split) == 2 { + link := append([]byte(``)...) + link = append(link, split[1]...) + link = append(link, []byte(``)...) + result = append(result, link...) + result = append(result, []byte("
")...) + continue + } + } + + heading := 0 + for i := 0; i < l; i++ { + if line[i] == '#' { + heading++ + } else { + break + } + } + if heading > 0 { + result = append(result, []byte(fmt.Sprintf("%s", heading, line, heading))...) + continue + } + + result = append(result, line...) + result = append(result, []byte("
")...) + } + + return result +} + +// Fetch downloads and converts a Gemini page. +func Fetch(u string, clientCertFile string, clientCertKey string) ([]byte, []byte, error) { + if u == "" { + return nil, nil, ErrInvalidURL + } + + requestURL, err := url.ParseRequestURI(u) + if err != nil { + return nil, nil, err + } + if requestURL.Scheme == "" { + requestURL.Scheme = "gemini" + } + if strings.IndexRune(requestURL.Host, ':') == -1 { + requestURL.Host += ":1965" + } + + tlsConfig := &tls.Config{} + + conn, err := tls.Dial("tcp", requestURL.Host, tlsConfig) + if err != nil { + return nil, nil, err + } + + // Send request header + conn.Write([]byte(requestURL.String() + "\r\n")) + + data, err := ioutil.ReadAll(conn) + if err != nil { + return nil, nil, err + } + + firstNewLine := -1 + l := len(data) + if l > 2 { + for i := 1; i < l; i++ { + if data[i] == '\n' && data[i-1] == '\r' { + firstNewLine = i + break + } + } + } + var header []byte + if firstNewLine > -1 { + header = data[:firstNewLine] + data = data[firstNewLine+1:] + } + + if bytes.HasPrefix(header, []byte("text/html")) { + return header, data, nil + } + return header, Convert(data, requestURL.String()), nil +} + +func handleRequest(writer http.ResponseWriter, request *http.Request) { + defer request.Body.Close() + if request.URL == nil { + return + } + + pathSplit := strings.Split(request.URL.Path, "/") + if len(pathSplit) < 2 || pathSplit[1] != "gemini" { + writer.Write([]byte("Error: invalid protocol, only Gemini is supported")) + return + } + + u, err := url.ParseRequestURI("gemini://" + strings.Join(pathSplit[2:], "/")) + if err != nil { + writer.Write([]byte("Error: invalid URL")) + return + } + + header, data, err := Fetch(u.String(), "", "") + if err != nil { + fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err) + return + } + + if len(header) > 0 && header[0] == '3' { + split := bytes.SplitN(header, []byte(" "), 2) + if len(split) == 2 { + writer.Header().Set("Location", rewriteURL(string(split[1]), request.URL)) + return + } + } + + if len(header) > 3 && !bytes.HasPrefix(header[3:], []byte("text/gemini")) { + writer.Header().Set("Content-type", string(header[3:])) + } else { + writer.Header().Set("Content-type", "text/html; charset=utf-8") + } + + writer.Write([]byte("\n\n\n")) + writer.Write(data) + writer.Write([]byte("\n\n")) +} + +// StartDaemon starts the page conversion daemon. +func StartDaemon(address string) error { + daemonAddress = address + + http.HandleFunc("/", handleRequest) + + go func() { + log.Fatal(http.ListenAndServe(address, nil)) + }() + return nil +}