]> Sergey Matveev's repositories - tofuproxy.git/blobdiff - rounds/gemini.go
gemini:// support
[tofuproxy.git] / rounds / gemini.go
diff --git a/rounds/gemini.go b/rounds/gemini.go
new file mode 100644 (file)
index 0000000..4876ff7
--- /dev/null
@@ -0,0 +1,265 @@
+/*
+tofuproxy -- flexible HTTP proxy, TLS terminator, X.509 certificates
+             manager, WARC/Gemini browser
+Copyright (C) 2021 Sergey Matveev <stargrave@stargrave.org>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, version 3 of the License.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package rounds
+
+import (
+       "bufio"
+       "bytes"
+       "context"
+       "fmt"
+       "html"
+       "io"
+       "log"
+       "net/http"
+       "strconv"
+       "strings"
+
+       "go.stargrave.org/tofuproxy/fifos"
+       ttls "go.stargrave.org/tofuproxy/tls"
+)
+
+const (
+       ContentTypeGemini = "text/gemini"
+       SchemeGemini      = "gemini://"
+       GeminiEntrypoint  = "https://gemini"
+       GeminiPort        = ":1965"
+)
+
+var GemCodeName = map[int]string{
+       10: "INPUT",
+       11: "SENSITIVE INPUT",
+       20: "SUCCESS",
+       30: "REDIRECT - TEMPORARY",
+       31: "REDIRECT - PERMANENT",
+       40: "TEMPORARY FAILURE",
+       41: "SERVER UNAVAILABLE",
+       42: "CGI ERROR",
+       43: "PROXY ERROR",
+       44: "SLOW DOWN",
+       50: "PERMANENT FAILURE",
+       51: "NOT FOUND",
+       52: "GONE",
+       53: "PROXY REQUEST REFUSED",
+       59: "BAD REQUEST",
+       60: "CLIENT CERTIFICATE REQUIRED",
+       61: "CERTIFICATE NOT AUTHORISED",
+       62: "CERTIFICATE NOT VALID",
+}
+
+func absolutizeURL(host, u string, paths ...string) string {
+       host = strings.TrimSuffix(host, GeminiPort)
+       if strings.Contains(u, "://") {
+               return u
+       }
+       if strings.HasPrefix(u, "/") {
+               return GeminiEntrypoint + "/" + host + u
+       }
+       paths = append([]string{GeminiEntrypoint, host}, paths...)
+       paths = append(paths, u)
+       return strings.Join(paths, "/")
+}
+
+func geminifyURL(host, u string, paths ...string) string {
+       u = absolutizeURL(host, u, paths...)
+       if !strings.HasPrefix(u, SchemeGemini) {
+               return u
+       }
+       return GeminiEntrypoint + "/" + strings.TrimPrefix(u, SchemeGemini)
+}
+
+func RoundGemini(
+       host string,
+       resp *http.Response,
+       w http.ResponseWriter,
+       req *http.Request,
+) (bool, error) {
+       if host != "gemini" {
+               return true, nil
+       }
+       paths := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
+       host, paths = paths[0], paths[1:]
+       hostWithPort := host
+       if !strings.Contains(hostWithPort, ":") {
+               hostWithPort += GeminiPort
+       }
+       conn, err := ttls.DialTLS(context.TODO(), "tcp", hostWithPort)
+       if err != nil {
+               log.Printf("%s: can not dial: %+v\n", req.URL, err)
+               return false, err
+       }
+       _, err = fmt.Fprintf(
+               conn, "%s%s/%s\r\n",
+               SchemeGemini, host, strings.Join(paths, "/"),
+       )
+       if err != nil {
+               log.Printf("%s: can not send request: %+v\n", req.URL, err)
+               return false, err
+       }
+       if len(paths) > 0 && paths[len(paths)-1] == "" {
+               paths = paths[:len(paths)-1]
+       }
+       br := bufio.NewReader(conn)
+       rawResp, err := br.ReadString('\n')
+       if err != nil {
+               log.Printf("%s: can not read response: %+v\n", req.URL, err)
+               return false, err
+       }
+       cols := strings.SplitN(rawResp, " ", 2)
+       if len(cols) < 2 {
+               err = fmt.Errorf("invalid response format: %s", rawResp)
+               log.Printf("%s: %s\n", req.URL, err)
+               return false, err
+       }
+       code, err := strconv.Atoi(cols[0])
+       if err != nil {
+               log.Printf("%s: can not parse response code: %+v\n", req.URL, err)
+               return false, err
+       }
+       codeName := GemCodeName[code]
+       if codeName == "" {
+               codeName = "UNKNOWN"
+       }
+       if 10 <= code && code <= 19 {
+               w.Header().Add("Content-Type", "text/plain")
+               w.WriteHeader(http.StatusBadRequest)
+               fmt.Fprintf(w, "%s\n%d (%s): INPUT is not supported\n", cols[1], code, codeName)
+               return false, nil
+       }
+       if 30 <= code && code <= 39 {
+               w.Header().Add("Content-Type", "text/html")
+               w.WriteHeader(http.StatusOK)
+               u := geminifyURL(host, cols[1], paths...)
+               w.Write([]byte(
+                       fmt.Sprintf(
+                               `<!DOCTYPE html>
+<html><head><title>%d (%s) redirection</title></head>
+<body>Redirection to <a href="%s">%s</a></body></html>`,
+                               code, codeName, u, u,
+                       )))
+               fifos.LogRedir <- fmt.Sprintf(
+                       "%s %s\t%d\t%s", req.Method, req.URL, code, cols[1],
+               )
+               return false, nil
+       }
+       if 40 <= code && code <= 49 {
+               w.Header().Add("Content-Type", "text/plain")
+               w.WriteHeader(http.StatusBadGateway)
+               fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
+               return false, nil
+       }
+       if 50 <= code && code <= 59 {
+               w.Header().Add("Content-Type", "text/plain")
+               w.WriteHeader(http.StatusBadGateway)
+               fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
+               return false, nil
+       }
+       if 60 <= code && code <= 69 {
+               w.Header().Add("Content-Type", "text/plain")
+               w.WriteHeader(http.StatusUnauthorized)
+               fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
+               return false, nil
+       }
+       if !(20 <= code && code <= 29) {
+               err = fmt.Errorf("unknown response code: %d", code)
+               log.Printf("%s: %s\n", req.URL, err)
+               return false, err
+       }
+       contentType := strings.Split(strings.TrimRight(cols[1], "\r\n"), ";")[0]
+       if contentType == ContentTypeGemini &&
+               !strings.Contains(req.Header.Get("Accept"), ContentTypeGemini) {
+               w.Header().Add("Content-Type", "text/html")
+               w.WriteHeader(http.StatusOK)
+               raw, err := io.ReadAll(br)
+               if err != nil {
+                       log.Printf("%s: can not read response body: %+v\n", req.URL, err)
+                       return false, err
+               }
+               var buf bytes.Buffer
+               fmt.Fprintf(&buf, `<!DOCTYPE html>
+<html><head><title>%d (%s)</title></head><body>
+`, code, codeName)
+               pre := false
+               for _, line := range strings.Split(string(raw), "\n") {
+                       if strings.HasPrefix(line, "```") {
+                               if pre {
+                                       buf.WriteString("</pre>\n")
+                               } else {
+                                       buf.WriteString("<pre>" + line[3:] + "\n")
+                               }
+                               pre = !pre
+                               continue
+                       }
+                       if pre {
+                               fmt.Fprintf(&buf, "%s\n", line)
+                               continue
+                       }
+                       if strings.HasPrefix(line, "=> ") {
+                               cols = strings.Fields(line)
+                               u := geminifyURL(host, cols[1], paths...)
+                               switch len(cols) {
+                               case 2:
+                                       fmt.Fprintf(
+                                               &buf, "<a href=\"%s\">%s</a><br/>\n",
+                                               u, html.EscapeString(cols[1]),
+                                       )
+                               default:
+                                       fmt.Fprintf(
+                                               &buf, "<a href=\"%s\">%s</a> (<tt>%s</tt>)<br/>\n",
+                                               u, html.EscapeString(strings.Join(cols[2:], " ")), cols[1],
+                                       )
+                               }
+                               continue
+                       }
+                       if strings.HasPrefix(line, "# ") {
+                               fmt.Fprintf(&buf, "<h1>%s</h1>\n", html.EscapeString(line[2:]))
+                               continue
+                       }
+                       if strings.HasPrefix(line, "## ") {
+                               fmt.Fprintf(&buf, "<h2>%s</h2>\n", html.EscapeString(line[3:]))
+                               continue
+                       }
+                       if strings.HasPrefix(line, "### ") {
+                               fmt.Fprintf(&buf, "<h3>%s</h3>\n", html.EscapeString(line[4:]))
+                               continue
+                       }
+                       if strings.HasPrefix(line, "* ") {
+                               fmt.Fprintf(&buf, "&bullet; %s\n", html.EscapeString(line[2:]))
+                               continue
+                       }
+                       if strings.HasPrefix(line, "> ") {
+                               fmt.Fprintf(
+                                       &buf, "<blockquote><tt>%s</tt></blockquote>\n",
+                                       html.EscapeString(line[2:]),
+                               )
+                               continue
+                       }
+                       fmt.Fprintf(&buf, "%s<br/>\n", html.EscapeString(line))
+               }
+               buf.WriteString("</body></html>\n")
+               _, err = w.Write(buf.Bytes())
+               return false, err
+       }
+       w.Header().Add("Content-Type", contentType)
+       w.WriteHeader(http.StatusOK)
+       _, err = io.Copy(w, br)
+       if err != nil {
+               log.Printf("%s: can not read response body: %+v\n", req.URL, err)
+       }
+       return false, err
+}