+/*
+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, "• %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
+}